From a20c4970965fad0203d8db4e76cbbcdac488ad2c Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Fri, 5 Jun 2026 10:17:10 -0300 Subject: [PATCH 01/27] =?UTF-8?q?feat(github):=20ingest=C3=A3o=20de=20cont?= =?UTF-8?q?ribui=C3=A7=C3=B5es=20e=20retrospectiva=20da=20comunidade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substitui a retrospectiva provisória (comando que rodava `gh` na mão e subia um JSON para um link temporário) por uma ingestão persistente que respeita a arquitetura modular do projeto. - integration-github: modelo próprio de contribuições (desacoplado de Interaction/gamification), lake bruto de eventos espelhando o do Discord, backfill REST resiliente e ciente de rate limit (helper RateLimit compartilhado), e webhook ao vivo com verificação de assinatura HMAC. - panel-admin: allowlist de repositórios gerenciável + ação "Backfill agora" com notificações amigáveis (rate limit / falha) sem marcar sucesso falso. - portal: página pública de retrospectiva por período (Livewire), contando todos os contribuidores, excluindo bots e distinguindo PRs mergeados dos fechados sem merge. A gamificação fica como seam (evento GithubContributionRecorded), sem dependência direta entre os módulos. --- .env.example | 3 + CONTEXT-MAP.md | 18 +- app-modules/integration-github/CONTEXT.md | 53 ++ .../factories/GithubContributionFactory.php | 44 ++ .../factories/GithubRepositoryFactory.php | 38 ++ ...00001_create_github_repositories_table.php | 26 + ...0002_create_github_contributions_table.php | 36 ++ ..._000003_create_github_event_logs_table.php | 28 + .../0001-github-community-contributions.md | 54 ++ .../routes/github-webhook-routes.php | 13 + .../src/Backfill/BackfillRepository.php | 230 +++++++++ .../src/Backfill/RateLimit.php | 38 ++ .../src/Console/BackfillGithubCommand.php | 68 +++ .../src/Contributions/RecordContribution.php | 49 ++ .../src/Enums/ContributionType.php | 14 + .../src/Events/GithubContributionRecorded.php | 24 + .../src/IntegrationGithubServiceProvider.php | 8 +- .../src/Models/GithubContribution.php | 52 ++ .../src/Models/GithubEventLog.php | 34 ++ .../src/Models/GithubRepository.php | 54 ++ .../src/Transport/GitHubApiConnector.php | 8 + .../Requests/Contributions/GetPullRequest.php | 23 + .../Requests/Contributions/ListCommits.php | 37 ++ .../Contributions/ListIssueComments.php | 37 ++ .../Requests/Contributions/ListIssues.php | 38 ++ .../ListPullRequestReviewComments.php | 37 ++ .../Contributions/ListPullRequestReviews.php | 33 ++ .../Contributions/ListPullRequests.php | 38 ++ .../src/Webhook/GithubWebhookController.php | 51 ++ .../src/Webhook/ProjectGithubEvent.php | 180 +++++++ .../src/Webhook/VerifyGithubSignature.php | 26 + .../Feature/BackfillGithubCommandTest.php | 61 +++ .../tests/Feature/BackfillRepositoryTest.php | 200 ++++++++ .../tests/Feature/GithubContributionTest.php | 47 ++ .../tests/Feature/GithubRepositoryTest.php | 28 + .../tests/Feature/GithubWebhookTest.php | 116 +++++ .../panel-admin/src/Github/GithubCluster.php | 30 ++ .../Resources/GithubRepositoryResource.php | 145 ++++++ .../Pages/CreateGithubRepository.php | 13 + .../Pages/EditGithubRepository.php | 19 + .../Pages/ListGithubRepositories.php | 19 + .../src/PanelAdminServiceProvider.php | 8 +- .../Github/GithubRepositoryResourceTest.php | 90 ++++ .../views/community-retrospective.blade.php | 99 ++++ .../Livewire/CommunityRetrospectivePage.php | 42 ++ .../portal/src/PortalServiceProvider.php | 2 + .../Retrospective/CommunityRetrospective.php | 144 ++++++ .../CommunityRetrospectivePageTest.php | 50 ++ .../Feature/CommunityRetrospectiveTest.php | 68 +++ config/services.php | 2 + ...26-06-04-github-community-contributions.md | 480 ++++++++++++++++++ 51 files changed, 3045 insertions(+), 10 deletions(-) create mode 100644 app-modules/integration-github/CONTEXT.md create mode 100644 app-modules/integration-github/database/factories/GithubContributionFactory.php create mode 100644 app-modules/integration-github/database/factories/GithubRepositoryFactory.php create mode 100644 app-modules/integration-github/database/migrations/2026_06_04_000001_create_github_repositories_table.php create mode 100644 app-modules/integration-github/database/migrations/2026_06_04_000002_create_github_contributions_table.php create mode 100644 app-modules/integration-github/database/migrations/2026_06_04_000003_create_github_event_logs_table.php create mode 100644 app-modules/integration-github/docs/adr/0001-github-community-contributions.md create mode 100644 app-modules/integration-github/routes/github-webhook-routes.php create mode 100644 app-modules/integration-github/src/Backfill/BackfillRepository.php create mode 100644 app-modules/integration-github/src/Backfill/RateLimit.php create mode 100644 app-modules/integration-github/src/Console/BackfillGithubCommand.php create mode 100644 app-modules/integration-github/src/Contributions/RecordContribution.php create mode 100644 app-modules/integration-github/src/Enums/ContributionType.php create mode 100644 app-modules/integration-github/src/Events/GithubContributionRecorded.php create mode 100644 app-modules/integration-github/src/Models/GithubContribution.php create mode 100644 app-modules/integration-github/src/Models/GithubEventLog.php create mode 100644 app-modules/integration-github/src/Models/GithubRepository.php create mode 100644 app-modules/integration-github/src/Transport/Requests/Contributions/GetPullRequest.php create mode 100644 app-modules/integration-github/src/Transport/Requests/Contributions/ListCommits.php create mode 100644 app-modules/integration-github/src/Transport/Requests/Contributions/ListIssueComments.php create mode 100644 app-modules/integration-github/src/Transport/Requests/Contributions/ListIssues.php create mode 100644 app-modules/integration-github/src/Transport/Requests/Contributions/ListPullRequestReviewComments.php create mode 100644 app-modules/integration-github/src/Transport/Requests/Contributions/ListPullRequestReviews.php create mode 100644 app-modules/integration-github/src/Transport/Requests/Contributions/ListPullRequests.php create mode 100644 app-modules/integration-github/src/Webhook/GithubWebhookController.php create mode 100644 app-modules/integration-github/src/Webhook/ProjectGithubEvent.php create mode 100644 app-modules/integration-github/src/Webhook/VerifyGithubSignature.php create mode 100644 app-modules/integration-github/tests/Feature/BackfillGithubCommandTest.php create mode 100644 app-modules/integration-github/tests/Feature/BackfillRepositoryTest.php create mode 100644 app-modules/integration-github/tests/Feature/GithubContributionTest.php create mode 100644 app-modules/integration-github/tests/Feature/GithubRepositoryTest.php create mode 100644 app-modules/integration-github/tests/Feature/GithubWebhookTest.php create mode 100644 app-modules/panel-admin/src/Github/GithubCluster.php create mode 100644 app-modules/panel-admin/src/Github/Resources/GithubRepositoryResource.php create mode 100644 app-modules/panel-admin/src/Github/Resources/GithubRepositoryResource/Pages/CreateGithubRepository.php create mode 100644 app-modules/panel-admin/src/Github/Resources/GithubRepositoryResource/Pages/EditGithubRepository.php create mode 100644 app-modules/panel-admin/src/Github/Resources/GithubRepositoryResource/Pages/ListGithubRepositories.php create mode 100644 app-modules/panel-admin/tests/Feature/Github/GithubRepositoryResourceTest.php create mode 100644 app-modules/portal/resources/views/community-retrospective.blade.php create mode 100644 app-modules/portal/src/Livewire/CommunityRetrospectivePage.php create mode 100644 app-modules/portal/src/Retrospective/CommunityRetrospective.php create mode 100644 app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php create mode 100644 app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php create mode 100644 docs/plans/2026-06-04-github-community-contributions.md diff --git a/.env.example b/.env.example index 792fe0262..d3aadc5ea 100644 --- a/.env.example +++ b/.env.example @@ -105,3 +105,6 @@ BACKUP_VPS_PASSWORD= BACKUP_VPS_KEY_PATH= BACKUP_VPS_PORT=22 BACKUP_VPS_ROOT=/backups + +GITHUB_API_TOKEN= +GITHUB_WEBHOOK_SECRET= diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md index ca3be0e7d..677c6644c 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -4,14 +4,15 @@ This is a modular monorepo (`internachi/modular`). Each bounded context lives un ## Contexts -| Context | Path | Description | -| ------------------- | ---------------------------------- | --------------------------------------------------------------------------- | -| Moderation | `app-modules/moderation/` | Content moderation pipeline — classification, routing, enforcement, appeals | -| Bot Discord | `app-modules/bot-discord/` | Discord bot runtime (Laracord websocket, slash commands, event handlers) | -| Integration Discord | `app-modules/integration-discord/` | Discord platform transport (REST API via Saloon), OAuth, ETL | -| Identity | `app-modules/identity/` | Users, tenants, external identities, authentication | -| Panel Admin | `app-modules/panel-admin/` | Filament admin panel — dashboards, resources, moderation UI, marketing | -| Integration Twitch | `app-modules/integration-twitch/` | Twitch platform transport (Helix API via Saloon), OAuth, EventSub webhooks | +| Context | Path | Description | +| ------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| Moderation | `app-modules/moderation/` | Content moderation pipeline — classification, routing, enforcement, appeals | +| Bot Discord | `app-modules/bot-discord/` | Discord bot runtime (Laracord websocket, slash commands, event handlers) | +| Integration Discord | `app-modules/integration-discord/` | Discord platform transport (REST API via Saloon), OAuth, ETL | +| Identity | `app-modules/identity/` | Users, tenants, external identities, authentication | +| Panel Admin | `app-modules/panel-admin/` | Filament admin panel — dashboards, resources, moderation UI, marketing | +| Integration Twitch | `app-modules/integration-twitch/` | Twitch platform transport (Helix API via Saloon), OAuth, EventSub webhooks | +| Integration GitHub | `app-modules/integration-github/` | GitHub transport (REST via Saloon), OAuth, community contribution ingestion (backfill + webhooks) + event lake | ## Relationships @@ -41,4 +42,5 @@ This is a modular monorepo (`internachi/modular`). Each bounded context lives un - **Bot Discord** depends on Moderation (listens to domain events) and Integration Discord (uses transport). - **Integration Discord** depends on Identity (OAuth user resolution). It never imports from Moderation. - **Integration Twitch** depends on Identity (OAuth user resolution, ExternalIdentity for tenant linking). It never imports from Moderation, Integration Discord, or Bot Discord. +- **Integration GitHub** depends on Identity (OAuth user resolution; future `Character` seam via `ExternalIdentity`). It never imports from Activity, Economy, Moderation or any Bot/runtime module — it only emits the `GithubContributionRecorded` domain event. The community presentation (in `portal`) and the allowlist admin UI (in `panel-admin`) depend on it, never the reverse. - **Identity** has no upstream dependencies on other contexts listed here. diff --git a/app-modules/integration-github/CONTEXT.md b/app-modules/integration-github/CONTEXT.md new file mode 100644 index 000000000..583ce00c2 --- /dev/null +++ b/app-modules/integration-github/CONTEXT.md @@ -0,0 +1,53 @@ +# Integration GitHub Context + +Transport and integration layer for the GitHub platform. Owns all HTTP communication with GitHub's REST API (via Saloon), OAuth flows, the ingestion of community contributions (backfill + webhooks), and the raw event lake. + +## Glossary + +| Term | Definition | Not to be confused with | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| **Transport** | The Saloon-based HTTP layer (`Transport/`) that sends requests to GitHub's REST API. All GitHub HTTP goes through here, authenticated with a PAT. | The OAuth connector (which exchanges user login codes) | +| **Contribution** | A normalized record of one thing a person did on a tracked repo: a PR, review, issue, comment or commit. Keyed by GitHub login — member or not. | `Interaction` (the gamification record in `activity`, members-only) | +| **Allowlist** | The set of repos that count, stored in `github_repositories` and managed in the admin panel. Ingestion (backfill + webhook) is scoped to it. | All org repos (the org webhook delivers everything; we filter) | +| **Event lake** | `github_event_logs` — the raw, append-only store of webhook payloads (deduped by delivery id), for audit and replay. | `github_contributions` (the normalized read model) | +| **Backfill** | Historical import via paginated REST list requests, upserting contributions directly. Resumable via `last_backfilled_at`. | Webhook ingestion (the live stream through the lake) | + +## Structure + +``` +src/ +├── Transport/ +│ ├── GitHubApiConnector.php ← REST base URL + PAT auth (services.github.api_token) +│ ├── GitHubOAuthConnector.php ← OAuth client credentials +│ └── Requests/ +│ ├── Users/ ← GetCurrentUser, GetUser +│ └── Contributions/ ← ListPullRequests, GetPullRequest, ListIssues, +│ ListReviews, ListIssueComments, ListPrComments, ListCommits +├── OAuth/ ← GitHubOAuthClient (implements OAuthClientContract) +├── Backfill/ ← BackfillRepository action + console command +├── Webhook/ ← VerifyGithubSignature, GithubWebhookController, ProjectGithubEvent +├── Models/ ← GithubRepository · GithubContribution · GithubEventLog +└── Events/ ← GithubContributionRecorded (seam for gamification) +``` + +## Module Boundaries + +### This module owns: + +- All HTTP requests to GitHub REST API (PAT + OAuth) +- OAuth token exchange and user profile retrieval +- The community contribution model and its ingestion (backfill + webhook) +- The raw GitHub event lake + +### This module does NOT own: + +- Gamification of contributions (coins/xp) — belongs to `activity`/`economy`; this module only **emits** `GithubContributionRecorded` +- The community presentation page — belongs to `portal` (reads `github_contributions`) +- The admin UI — the Filament resources live in `panel-admin` + +## Dependencies + +- **Identity** — OAuth user resolution (`OAuthClientContract`, `OAuthUserDTO`); and, as a future seam, resolving a `Character` from a GitHub `ExternalIdentity`. +- **No dependency on** Activity, Economy, Moderation or any Bot/runtime module. + +See `docs/adr/0001-github-community-contributions.md` for the decisions behind this design. diff --git a/app-modules/integration-github/database/factories/GithubContributionFactory.php b/app-modules/integration-github/database/factories/GithubContributionFactory.php new file mode 100644 index 000000000..3f7b832d5 --- /dev/null +++ b/app-modules/integration-github/database/factories/GithubContributionFactory.php @@ -0,0 +1,44 @@ + + */ +final class GithubContributionFactory extends Factory +{ + protected $model = GithubContribution::class; + + /** + * @return array + */ + public function definition(): array + { + $number = fake()->unique()->numberBetween(1, 1_000_000); + + return [ + 'repo' => 'he4rt/heartdevs.com', + 'actor_login' => fake()->userName(), + 'actor_id' => fake()->numberBetween(1, 9_999_999), + 'type' => ContributionType::Pr, + 'external_ref' => 'pr:'.$number, + 'target_ref' => null, + 'occurred_at' => now(), + 'metadata' => [], + ]; + } + + public function bot(): self + { + return $this->state(fn (array $attributes): array => [ + 'actor_login' => 'dependabot[bot]', + 'metadata' => [...$attributes['metadata'] ?? [], 'is_bot' => true], + ]); + } +} diff --git a/app-modules/integration-github/database/factories/GithubRepositoryFactory.php b/app-modules/integration-github/database/factories/GithubRepositoryFactory.php new file mode 100644 index 000000000..e11163bf0 --- /dev/null +++ b/app-modules/integration-github/database/factories/GithubRepositoryFactory.php @@ -0,0 +1,38 @@ + + */ +final class GithubRepositoryFactory extends Factory +{ + protected $model = GithubRepository::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'full_name' => 'he4rt/'.fake()->unique()->slug(2), + 'enabled' => true, + 'last_backfilled_at' => null, + ]; + } + + public function disabled(): self + { + return $this->state(['enabled' => false]); + } + + public function backfilled(): self + { + return $this->state(['last_backfilled_at' => now()]); + } +} diff --git a/app-modules/integration-github/database/migrations/2026_06_04_000001_create_github_repositories_table.php b/app-modules/integration-github/database/migrations/2026_06_04_000001_create_github_repositories_table.php new file mode 100644 index 000000000..54b5d07d1 --- /dev/null +++ b/app-modules/integration-github/database/migrations/2026_06_04_000001_create_github_repositories_table.php @@ -0,0 +1,26 @@ +uuid('id')->primary(); + $table->string('full_name')->unique(); // owner/repo + $table->boolean('enabled')->default(true); + $table->timestamp('last_backfilled_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('github_repositories'); + } +}; diff --git a/app-modules/integration-github/database/migrations/2026_06_04_000002_create_github_contributions_table.php b/app-modules/integration-github/database/migrations/2026_06_04_000002_create_github_contributions_table.php new file mode 100644 index 000000000..faf988d3d --- /dev/null +++ b/app-modules/integration-github/database/migrations/2026_06_04_000002_create_github_contributions_table.php @@ -0,0 +1,36 @@ +uuid('id')->primary(); + $table->string('repo'); + $table->string('actor_login'); + $table->unsignedBigInteger('actor_id')->nullable(); + $table->string('type'); + $table->string('external_ref'); + $table->string('target_ref')->nullable(); + $table->timestamp('occurred_at'); + $table->jsonb('metadata')->nullable(); + $table->timestamps(); + + $table->unique(['repo', 'type', 'external_ref'], 'uniq_github_contributions_ref'); + $table->index(['repo', 'occurred_at'], 'idx_github_contributions_repo_time'); + $table->index('actor_id', 'idx_github_contributions_actor'); + $table->index(['type', 'occurred_at'], 'idx_github_contributions_type_time'); + }); + } + + public function down(): void + { + Schema::dropIfExists('github_contributions'); + } +}; diff --git a/app-modules/integration-github/database/migrations/2026_06_04_000003_create_github_event_logs_table.php b/app-modules/integration-github/database/migrations/2026_06_04_000003_create_github_event_logs_table.php new file mode 100644 index 000000000..fe5225dfd --- /dev/null +++ b/app-modules/integration-github/database/migrations/2026_06_04_000003_create_github_event_logs_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('event_type')->index(); + $table->string('repo')->nullable()->index(); + $table->string('actor_login')->nullable(); + $table->string('delivery_id')->nullable()->unique(); + $table->jsonb('payload'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('github_event_logs'); + } +}; diff --git a/app-modules/integration-github/docs/adr/0001-github-community-contributions.md b/app-modules/integration-github/docs/adr/0001-github-community-contributions.md new file mode 100644 index 000000000..af43ec63d --- /dev/null +++ b/app-modules/integration-github/docs/adr/0001-github-community-contributions.md @@ -0,0 +1,54 @@ +# ADR-0001: GitHub Community Contributions + +**Status:** Accepted +**Date:** 2026-06-04 +**Deciders:** Clintonrocha98 + +## Context + +The community wanted a recurring presentation ("Quem fez a He4rt bater") showing who is contributing across the community's public GitHub repos. The first attempt was a provisional artisan command (`community:retrospective`) that ran `gh` live, aggregated a JSON in-memory and uploaded it to a paste host. It did not persist anything, ran outside the modular architecture (under `app/Retrospective/`), and only worked for a single repo at a time. + +We need a persistent, queryable model that: + +- Counts **all** contributors by GitHub login — whether or not they are registered He4rt members. +- Captures **history** (backfill) and **new activity** (live). +- Spans **multiple repos**, curated and maintainable without code changes. +- Lives inside the modular architecture and respects boundaries. + +The existing `activity` domain has an `Interaction` model, but it is gamification-coupled: it requires a `character_id` (a registered member) and a `tenant_id`, and carries coins/xp. It cannot represent a GitHub contributor who never joined the platform. + +## Decision + +Build the ingestion in `integration-github` (transport), mirroring the Discord data-lake pattern. + +### 1. Own contribution model, not `Interaction` + +A normalized `github_contributions` table keyed by GitHub login/id (member-or-not), with `unique(repo, type, external_ref)` for idempotency. Gamification is left as a **seam**: this module emits `GithubContributionRecorded` and does not depend on `activity`/`economy`. + +### 2. Normalized read model + raw lake + +`github_contributions` is the read model the presentation queries. `github_event_logs` is the raw, append-only webhook lake (deduped by delivery id) for audit/replay — exactly mirroring `discord_event_logs`. + +### 3. Split ingestion paths + +- **Webhook (live):** org webhook → signature-verified → raw payload into the lake → `ProjectGithubEvent` projects into contributions. +- **Backfill (history):** paginated REST list requests upsert contributions directly. Resumable via `last_backfilled_at`. The `unique(repo, type, external_ref)` key makes both paths converge without duplicates. + +### 4. Panel-managed allowlist + +`github_repositories` (managed in `panel-admin`) is the source of truth for which repos count. The org webhook delivers everything; ingestion filters by the allowlist. + +### 5. Store everything, filter on read + +Everything is stored raw. The presentation excludes only **bots** at read time. **Closed-unmerged PRs count as participation** (consistent with the "all contributors" audience), but are broken out by outcome (`prs_merged` / `prs_unmerged`) so the view distinguishes work that landed from work that was rejected/abandoned. Because filtering lives only in the read model, this refinement was a one-function change with no re-backfill — exactly the payoff of storing everything raw. + +### 6. Auth and scope + +A fine-grained PAT (`services.github.api_token`) authenticates the REST connector for backfill; a manually-registered org webhook with a shared secret (`services.github.webhook_secret`) feeds the live stream. Contribution types in scope: PR, review, issue, comment, commit (no reactions — GitHub has no reaction webhook). + +## Consequences + +- **Positive:** the presentation is fast and queryable by period/person/type; history and live converge idempotently; gamification can be wired later without re-modeling; adding a repo is a panel action. +- **Negative:** PR size enrichment costs one extra request per PR (the dominant cost — ~2 requests/PR); reactions are out of scope; the org webhook delivers traffic for non-allowlisted repos (stored in the lake for audit, then ignored). +- **Backfill scale & resilience:** the backfill is **synchronous and rate-limit-aware** — every request uses Saloon `->throw()`, so a 403/5xx fails loudly (no silent corruption); on a rate-limit 403 both backfill transports (the `github:backfill` console command and the panel's "Backfill agora" action) surface a clear, actionable message and is safely resumable (idempotent upsert means a re-run after the reset costs nothing extra and never duplicates). The rate-limit interpretation lives in a single shared `Backfill\RateLimit` helper so the two transports cannot drift. At current scale this is enough: the main repo (~230 PRs) is ~500 requests, ~10% of the 5,000/hour budget. A **queue** (job-per-repo with release-on-limit) and a true **incremental** refresh (using `last_backfilled_at` as `since` — today it is recorded but each run still does full history) are deliberately deferred as YAGNI; revisit if many large repos are added. +- **Follow-up:** revisit a lake retention/TTL policy; decide whether commits without a linked GitHub user enter the per-person ranking; implement the gamification bridge via the seam event. diff --git a/app-modules/integration-github/routes/github-webhook-routes.php b/app-modules/integration-github/routes/github-webhook-routes.php new file mode 100644 index 000000000..bb861b45d --- /dev/null +++ b/app-modules/integration-github/routes/github-webhook-routes.php @@ -0,0 +1,13 @@ +middleware([VerifyGithubSignature::class]) + ->group(function (): void { + Route::post('/', GithubWebhookController::class)->name('github.webhook'); + }); diff --git a/app-modules/integration-github/src/Backfill/BackfillRepository.php b/app-modules/integration-github/src/Backfill/BackfillRepository.php new file mode 100644 index 000000000..b6f19ca7b --- /dev/null +++ b/app-modules/integration-github/src/Backfill/BackfillRepository.php @@ -0,0 +1,230 @@ +backfillPullRequests($repo); + $this->backfillIssues($repo); + $this->backfillIssueComments($repo); + $this->backfillReviewComments($repo); + $this->backfillCommits($repo); + } + + private function backfillPullRequests(string $repo): void + { + $this->paginate( + fn (int $page): Request => new ListPullRequests($repo, $page, self::PER_PAGE), + function (array $pr) use ($repo): void { + $number = (int) ($pr['number'] ?? 0); + /** @var array $detail */ + $detail = (array) $this->github->send(new GetPullRequest($repo, $number))->throw()->json(); + $login = $this->login($pr); + + $this->upsert($repo, ContributionType::Pr, 'pr:'.$number, $login, $this->userId($pr), $this->strv($pr, 'created_at'), null, [ + 'title' => $pr['title'] ?? null, + 'state' => $pr['state'] ?? null, + 'merged' => ($pr['merged_at'] ?? null) !== null, + 'url' => $pr['html_url'] ?? null, + 'additions' => (int) ($detail['additions'] ?? 0), + 'deletions' => (int) ($detail['deletions'] ?? 0), + 'changed_files' => (int) ($detail['changed_files'] ?? 0), + 'is_bot' => $this->isBot($login), + ]); + + $this->backfillReviews($repo, $number); + }, + ); + } + + private function backfillReviews(string $repo, int $number): void + { + $this->paginate( + fn (int $page): Request => new ListPullRequestReviews($repo, $number, $page, self::PER_PAGE), + function (array $review) use ($repo, $number): void { + $login = $this->login($review); + + $this->upsert($repo, ContributionType::Review, 'review:'.($review['id'] ?? ''), $login, $this->userId($review), $this->strv($review, 'submitted_at'), 'pr:'.$number, [ + 'state' => $review['state'] ?? null, + 'is_bot' => $this->isBot($login), + ]); + }, + ); + } + + private function backfillIssues(string $repo): void + { + $this->paginate( + fn (int $page): Request => new ListIssues($repo, $page, self::PER_PAGE), + function (array $issue) use ($repo): void { + if (isset($issue['pull_request'])) { + return; // o endpoint de issues também devolve PRs; estes já entram via backfillPullRequests + } + + $login = $this->login($issue); + + $this->upsert($repo, ContributionType::Issue, 'issue:'.($issue['number'] ?? ''), $login, $this->userId($issue), $this->strv($issue, 'created_at'), null, [ + 'title' => $issue['title'] ?? null, + 'state' => $issue['state'] ?? null, + 'url' => $issue['html_url'] ?? null, + 'is_bot' => $this->isBot($login), + ]); + }, + ); + } + + private function backfillIssueComments(string $repo): void + { + $this->paginate( + fn (int $page): Request => new ListIssueComments($repo, $page, self::PER_PAGE), + function (array $comment) use ($repo): void { + $login = $this->login($comment); + + $this->upsert($repo, ContributionType::Comment, 'comment:'.($comment['id'] ?? ''), $login, $this->userId($comment), $this->strv($comment, 'created_at'), $this->refFromUrl($this->strv($comment, 'issue_url'), 'issue'), [ + 'url' => $comment['html_url'] ?? null, + 'kind' => 'issue', + 'is_bot' => $this->isBot($login), + ]); + }, + ); + } + + private function backfillReviewComments(string $repo): void + { + $this->paginate( + fn (int $page): Request => new ListPullRequestReviewComments($repo, $page, self::PER_PAGE), + function (array $comment) use ($repo): void { + $login = $this->login($comment); + + $this->upsert($repo, ContributionType::Comment, 'review_comment:'.($comment['id'] ?? ''), $login, $this->userId($comment), $this->strv($comment, 'created_at'), $this->refFromUrl($this->strv($comment, 'pull_request_url'), 'pr'), [ + 'url' => $comment['html_url'] ?? null, + 'kind' => 'pr', + 'is_bot' => $this->isBot($login), + ]); + }, + ); + } + + private function backfillCommits(string $repo): void + { + $this->paginate( + fn (int $page): Request => new ListCommits($repo, $page, self::PER_PAGE), + function (array $commit) use ($repo): void { + $author = is_array($commit['author'] ?? null) ? $commit['author'] : []; + $commitMeta = is_array($commit['commit'] ?? null) ? $commit['commit'] : []; + $commitAuthor = is_array($commitMeta['author'] ?? null) ? $commitMeta['author'] : []; + + $login = isset($author['login']) && is_string($author['login']) + ? $author['login'] + : (isset($commitAuthor['name']) && is_string($commitAuthor['name']) ? $commitAuthor['name'] : 'ghost'); + + $actorId = isset($author['id']) && is_numeric($author['id']) ? (int) $author['id'] : null; + $date = isset($commitAuthor['date']) && is_string($commitAuthor['date']) ? $commitAuthor['date'] : ''; + + $this->upsert($repo, ContributionType::Commit, 'commit:'.($commit['sha'] ?? ''), $login, $actorId, $date, null, [ + 'url' => $commit['html_url'] ?? null, + 'is_bot' => $this->isBot($login), + ]); + }, + ); + } + + /** + * @param callable(int): Request $request + * @param callable(array): void $handle + */ + private function paginate(callable $request, callable $handle): void + { + $page = 1; + + do { + /** @var list> $items */ + $items = (array) $this->github->send($request($page))->throw()->json(); + + foreach ($items as $item) { + $handle($item); + } + + $page++; + } while (count($items) === self::PER_PAGE); + } + + /** + * @param array $metadata + */ + private function upsert( + string $repo, + ContributionType $type, + string $externalRef, + string $actorLogin, + ?int $actorId, + string $occurredAt, + ?string $targetRef, + array $metadata, + ): void { + $this->recorder->execute($repo, $type, $externalRef, $actorLogin, $actorId, $occurredAt, $targetRef, $metadata); + } + + /** + * @param array $payload + */ + private function login(array $payload): string + { + $user = $payload['user'] ?? null; + + return is_array($user) && isset($user['login']) && is_string($user['login']) ? $user['login'] : 'ghost'; + } + + /** + * @param array $payload + */ + private function userId(array $payload): ?int + { + $user = $payload['user'] ?? null; + + return is_array($user) && isset($user['id']) && is_numeric($user['id']) ? (int) $user['id'] : null; + } + + /** + * @param array $payload + */ + private function strv(array $payload, string $key): string + { + $value = $payload[$key] ?? null; + + return is_scalar($value) ? (string) $value : ''; + } + + private function refFromUrl(string $url, string $prefix): ?string + { + return preg_match('~/(\d+)$~', $url, $matches) === 1 ? $prefix.':'.$matches[1] : null; + } + + private function isBot(string $login): bool + { + return str_ends_with($login, '[bot]'); + } +} diff --git a/app-modules/integration-github/src/Backfill/RateLimit.php b/app-modules/integration-github/src/Backfill/RateLimit.php new file mode 100644 index 000000000..bcae7296e --- /dev/null +++ b/app-modules/integration-github/src/Backfill/RateLimit.php @@ -0,0 +1,38 @@ +getResponse(); + + return $response->status() === 403 + && ($response->header('X-RateLimit-Remaining') === '0' + || str_contains(mb_strtolower($response->body()), 'rate limit')); + } + + /** + * Sufixo " (reset ~HH:MM)" quando o GitHub informa o horário de reset. + */ + public static function resetHint(RequestException $exception): string + { + $reset = $exception->getResponse()->header('X-RateLimit-Reset'); + + return is_numeric($reset) + ? sprintf(' (reset ~%s)', Date::createFromTimestamp((int) $reset)->format('H:i')) + : ''; + } +} diff --git a/app-modules/integration-github/src/Console/BackfillGithubCommand.php b/app-modules/integration-github/src/Console/BackfillGithubCommand.php new file mode 100644 index 000000000..31ad37414 --- /dev/null +++ b/app-modules/integration-github/src/Console/BackfillGithubCommand.php @@ -0,0 +1,68 @@ +argument('repo'); + + $repositories = is_string($repo) + ? GithubRepository::query()->where('full_name', $repo)->get() + : GithubRepository::query()->enabled()->get(); + + if ($repositories->isEmpty()) { + $this->warn('Nenhum repositório para backfill (verifique a allowlist no painel).'); + + return self::SUCCESS; + } + + foreach ($repositories as $repository) { + $this->info(sprintf('Backfilling %s...', $repository->full_name)); + + try { + $backfill->execute($repository->full_name); + } catch (RequestException $exception) { + if (RateLimit::matches($exception)) { + $this->warn(sprintf( + 'Rate limit do GitHub atingido em %s. Os dados já coletados foram salvos; rode novamente após o reset%s.', + $repository->full_name, + RateLimit::resetHint($exception), + )); + + return self::FAILURE; + } + + $this->error(sprintf('Falha em %s: %s', $repository->full_name, $exception->getMessage())); + + continue; + } catch (Throwable $throwable) { + $this->error(sprintf('Falha em %s: %s', $repository->full_name, $throwable->getMessage())); + + continue; + } + + $repository->update(['last_backfilled_at' => Date::now()]); + } + + $this->info('Backfill concluído.'); + + return self::SUCCESS; + } +} diff --git a/app-modules/integration-github/src/Contributions/RecordContribution.php b/app-modules/integration-github/src/Contributions/RecordContribution.php new file mode 100644 index 000000000..b940e3d31 --- /dev/null +++ b/app-modules/integration-github/src/Contributions/RecordContribution.php @@ -0,0 +1,49 @@ + $metadata + */ + public function execute( + string $repo, + ContributionType $type, + string $externalRef, + string $actorLogin, + ?int $actorId, + string $occurredAt, + ?string $targetRef, + array $metadata, + bool $emit = false, + ): GithubContribution { + $contribution = GithubContribution::query()->updateOrCreate( + ['repo' => $repo, 'type' => $type, 'external_ref' => $externalRef], + [ + 'actor_login' => $actorLogin, + 'actor_id' => $actorId, + 'target_ref' => $targetRef, + 'occurred_at' => $occurredAt, + 'metadata' => $metadata, + ], + ); + + if ($emit) { + event(new GithubContributionRecorded($contribution)); + } + + return $contribution; + } +} diff --git a/app-modules/integration-github/src/Enums/ContributionType.php b/app-modules/integration-github/src/Enums/ContributionType.php new file mode 100644 index 000000000..08d204bc2 --- /dev/null +++ b/app-modules/integration-github/src/Enums/ContributionType.php @@ -0,0 +1,14 @@ +app->singleton(GitHubApiConnector::class, fn () => new GitHubApiConnector()); } - public function boot(): void {} + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->commands([BackfillGithubCommand::class]); + } + } } diff --git a/app-modules/integration-github/src/Models/GithubContribution.php b/app-modules/integration-github/src/Models/GithubContribution.php new file mode 100644 index 000000000..ae23999d2 --- /dev/null +++ b/app-modules/integration-github/src/Models/GithubContribution.php @@ -0,0 +1,52 @@ +|null $metadata + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + */ +#[Table(name: 'github_contributions')] +final class GithubContribution extends Model +{ + /** @use HasFactory */ + use HasFactory; + use HasUuids; + + protected static function newFactory(): GithubContributionFactory + { + return GithubContributionFactory::new(); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => ContributionType::class, + 'actor_id' => 'integer', + 'occurred_at' => 'datetime', + 'metadata' => 'array', + ]; + } +} diff --git a/app-modules/integration-github/src/Models/GithubEventLog.php b/app-modules/integration-github/src/Models/GithubEventLog.php new file mode 100644 index 000000000..d63daead5 --- /dev/null +++ b/app-modules/integration-github/src/Models/GithubEventLog.php @@ -0,0 +1,34 @@ + $payload + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + */ +final class GithubEventLog extends Model +{ + /** + * @return array + */ + protected function casts(): array + { + return [ + 'payload' => 'array', + ]; + } +} diff --git a/app-modules/integration-github/src/Models/GithubRepository.php b/app-modules/integration-github/src/Models/GithubRepository.php new file mode 100644 index 000000000..da9321d91 --- /dev/null +++ b/app-modules/integration-github/src/Models/GithubRepository.php @@ -0,0 +1,54 @@ + */ + use HasFactory; + use HasUuids; + + protected static function newFactory(): GithubRepositoryFactory + { + return GithubRepositoryFactory::new(); + } + + /** + * @param Builder $query + * @return Builder + */ + protected function scopeEnabled(Builder $query): Builder + { + return $query->where('enabled', true); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'enabled' => 'boolean', + 'last_backfilled_at' => 'datetime', + ]; + } +} diff --git a/app-modules/integration-github/src/Transport/GitHubApiConnector.php b/app-modules/integration-github/src/Transport/GitHubApiConnector.php index b8d3ef828..d13e5ff55 100644 --- a/app-modules/integration-github/src/Transport/GitHubApiConnector.php +++ b/app-modules/integration-github/src/Transport/GitHubApiConnector.php @@ -4,6 +4,7 @@ namespace He4rt\IntegrationGithub\Transport; +use Saloon\Http\Auth\TokenAuthenticator; use Saloon\Http\Connector; use Saloon\Traits\Plugins\HasTimeout; @@ -20,6 +21,13 @@ public function resolveBaseUrl(): string return 'https://api.github.com'; } + protected function defaultAuth(): ?TokenAuthenticator + { + $token = config('services.github.api_token'); + + return is_string($token) && $token !== '' ? new TokenAuthenticator($token) : null; + } + /** * @return array */ diff --git a/app-modules/integration-github/src/Transport/Requests/Contributions/GetPullRequest.php b/app-modules/integration-github/src/Transport/Requests/Contributions/GetPullRequest.php new file mode 100644 index 000000000..12a4a8bc3 --- /dev/null +++ b/app-modules/integration-github/src/Transport/Requests/Contributions/GetPullRequest.php @@ -0,0 +1,23 @@ +repo.'/pulls/'.$this->number; + } +} diff --git a/app-modules/integration-github/src/Transport/Requests/Contributions/ListCommits.php b/app-modules/integration-github/src/Transport/Requests/Contributions/ListCommits.php new file mode 100644 index 000000000..af64c4386 --- /dev/null +++ b/app-modules/integration-github/src/Transport/Requests/Contributions/ListCommits.php @@ -0,0 +1,37 @@ +repo.'/commits'; + } + + /** + * @return array + */ + protected function defaultQuery(): array + { + return array_filter([ + 'per_page' => $this->perPage, + 'page' => $this->page, + 'since' => $this->since, + ], fn (mixed $value): bool => $value !== null); + } +} diff --git a/app-modules/integration-github/src/Transport/Requests/Contributions/ListIssueComments.php b/app-modules/integration-github/src/Transport/Requests/Contributions/ListIssueComments.php new file mode 100644 index 000000000..99705585e --- /dev/null +++ b/app-modules/integration-github/src/Transport/Requests/Contributions/ListIssueComments.php @@ -0,0 +1,37 @@ +repo.'/issues/comments'; + } + + /** + * @return array + */ + protected function defaultQuery(): array + { + return [ + 'sort' => 'created', + 'direction' => 'asc', + 'per_page' => $this->perPage, + 'page' => $this->page, + ]; + } +} diff --git a/app-modules/integration-github/src/Transport/Requests/Contributions/ListIssues.php b/app-modules/integration-github/src/Transport/Requests/Contributions/ListIssues.php new file mode 100644 index 000000000..e1d7ffec7 --- /dev/null +++ b/app-modules/integration-github/src/Transport/Requests/Contributions/ListIssues.php @@ -0,0 +1,38 @@ +repo.'/issues'; + } + + /** + * @return array + */ + protected function defaultQuery(): array + { + return [ + 'state' => 'all', + 'sort' => 'created', + 'direction' => 'asc', + 'per_page' => $this->perPage, + 'page' => $this->page, + ]; + } +} diff --git a/app-modules/integration-github/src/Transport/Requests/Contributions/ListPullRequestReviewComments.php b/app-modules/integration-github/src/Transport/Requests/Contributions/ListPullRequestReviewComments.php new file mode 100644 index 000000000..b3806fb45 --- /dev/null +++ b/app-modules/integration-github/src/Transport/Requests/Contributions/ListPullRequestReviewComments.php @@ -0,0 +1,37 @@ +repo.'/pulls/comments'; + } + + /** + * @return array + */ + protected function defaultQuery(): array + { + return [ + 'sort' => 'created', + 'direction' => 'asc', + 'per_page' => $this->perPage, + 'page' => $this->page, + ]; + } +} diff --git a/app-modules/integration-github/src/Transport/Requests/Contributions/ListPullRequestReviews.php b/app-modules/integration-github/src/Transport/Requests/Contributions/ListPullRequestReviews.php new file mode 100644 index 000000000..2c5e9e369 --- /dev/null +++ b/app-modules/integration-github/src/Transport/Requests/Contributions/ListPullRequestReviews.php @@ -0,0 +1,33 @@ +repo.'/pulls/'.$this->number.'/reviews'; + } + + /** + * @return array + */ + protected function defaultQuery(): array + { + return ['per_page' => $this->perPage, 'page' => $this->page]; + } +} diff --git a/app-modules/integration-github/src/Transport/Requests/Contributions/ListPullRequests.php b/app-modules/integration-github/src/Transport/Requests/Contributions/ListPullRequests.php new file mode 100644 index 000000000..781702461 --- /dev/null +++ b/app-modules/integration-github/src/Transport/Requests/Contributions/ListPullRequests.php @@ -0,0 +1,38 @@ +repo.'/pulls'; + } + + /** + * @return array + */ + protected function defaultQuery(): array + { + return [ + 'state' => 'all', + 'sort' => 'created', + 'direction' => 'asc', + 'per_page' => $this->perPage, + 'page' => $this->page, + ]; + } +} diff --git a/app-modules/integration-github/src/Webhook/GithubWebhookController.php b/app-modules/integration-github/src/Webhook/GithubWebhookController.php new file mode 100644 index 000000000..b86b9e307 --- /dev/null +++ b/app-modules/integration-github/src/Webhook/GithubWebhookController.php @@ -0,0 +1,51 @@ +header('X-GitHub-Event', ''); + $delivery = $request->header('X-GitHub-Delivery'); + + /** @var array $payload */ + $payload = $request->json()->all(); + + $log = GithubEventLog::query()->firstOrCreate( + ['delivery_id' => $delivery], + [ + 'event_type' => $event, + 'repo' => $this->stringOrNull($payload, 'repository.full_name'), + 'actor_login' => $this->stringOrNull($payload, 'sender.login'), + 'payload' => $payload, + ], + ); + + if ($log->wasRecentlyCreated) { + $this->projector->execute($event, $payload); + } + + return response()->noContent(); + } + + /** + * @param array $payload + */ + private function stringOrNull(array $payload, string $path): ?string + { + $value = data_get($payload, $path); + + return is_string($value) ? $value : null; + } +} diff --git a/app-modules/integration-github/src/Webhook/ProjectGithubEvent.php b/app-modules/integration-github/src/Webhook/ProjectGithubEvent.php new file mode 100644 index 000000000..093825b6b --- /dev/null +++ b/app-modules/integration-github/src/Webhook/ProjectGithubEvent.php @@ -0,0 +1,180 @@ + $payload + */ + public function execute(string $event, array $payload): void + { + $repo = $this->str($payload, 'repository.full_name'); + + if ($repo === '' || !$this->allowlisted($repo)) { + return; + } + + match ($event) { + 'pull_request' => $this->pullRequest($repo, $payload), + 'pull_request_review' => $this->review($repo, $payload), + 'issues' => $this->issue($repo, $payload), + 'issue_comment' => $this->issueComment($repo, $payload), + 'pull_request_review_comment' => $this->reviewComment($repo, $payload), + 'push' => $this->push($repo, $payload), + default => null, + }; + } + + private function allowlisted(string $repo): bool + { + return GithubRepository::query()->enabled()->where('full_name', $repo)->exists(); + } + + /** + * @param array $payload + */ + private function pullRequest(string $repo, array $payload): void + { + $pr = $this->arr($payload, 'pull_request'); + $login = $this->str($pr, 'user.login', 'ghost'); + + $this->recorder->execute($repo, ContributionType::Pr, 'pr:'.$this->str($pr, 'number'), $login, $this->intOrNull($pr, 'user.id'), $this->str($pr, 'created_at'), null, [ + 'title' => data_get($pr, 'title'), + 'state' => data_get($pr, 'state'), + 'merged' => data_get($pr, 'merged_at') !== null, + 'url' => data_get($pr, 'html_url'), + 'additions' => $this->intOrNull($pr, 'additions') ?? 0, + 'deletions' => $this->intOrNull($pr, 'deletions') ?? 0, + 'changed_files' => $this->intOrNull($pr, 'changed_files') ?? 0, + 'is_bot' => str_ends_with($login, '[bot]'), + ], emit: true); + } + + /** + * @param array $payload + */ + private function review(string $repo, array $payload): void + { + $review = $this->arr($payload, 'review'); + $login = $this->str($review, 'user.login', 'ghost'); + + $this->recorder->execute($repo, ContributionType::Review, 'review:'.$this->str($review, 'id'), $login, $this->intOrNull($review, 'user.id'), $this->str($review, 'submitted_at'), 'pr:'.$this->str($payload, 'pull_request.number'), [ + 'state' => data_get($review, 'state'), + 'is_bot' => str_ends_with($login, '[bot]'), + ], emit: true); + } + + /** + * @param array $payload + */ + private function issue(string $repo, array $payload): void + { + $issue = $this->arr($payload, 'issue'); + $login = $this->str($issue, 'user.login', 'ghost'); + + $this->recorder->execute($repo, ContributionType::Issue, 'issue:'.$this->str($issue, 'number'), $login, $this->intOrNull($issue, 'user.id'), $this->str($issue, 'created_at'), null, [ + 'title' => data_get($issue, 'title'), + 'state' => data_get($issue, 'state'), + 'url' => data_get($issue, 'html_url'), + 'is_bot' => str_ends_with($login, '[bot]'), + ], emit: true); + } + + /** + * @param array $payload + */ + private function issueComment(string $repo, array $payload): void + { + $comment = $this->arr($payload, 'comment'); + $login = $this->str($comment, 'user.login', 'ghost'); + $isPr = data_get($payload, 'issue.pull_request') !== null; + $target = ($isPr ? 'pr:' : 'issue:').$this->str($payload, 'issue.number'); + + $this->recorder->execute($repo, ContributionType::Comment, 'comment:'.$this->str($comment, 'id'), $login, $this->intOrNull($comment, 'user.id'), $this->str($comment, 'created_at'), $target, [ + 'url' => data_get($comment, 'html_url'), + 'kind' => $isPr ? 'pr' : 'issue', + 'is_bot' => str_ends_with($login, '[bot]'), + ], emit: true); + } + + /** + * @param array $payload + */ + private function reviewComment(string $repo, array $payload): void + { + $comment = $this->arr($payload, 'comment'); + $login = $this->str($comment, 'user.login', 'ghost'); + + $this->recorder->execute($repo, ContributionType::Comment, 'review_comment:'.$this->str($comment, 'id'), $login, $this->intOrNull($comment, 'user.id'), $this->str($comment, 'created_at'), 'pr:'.$this->str($payload, 'pull_request.number'), [ + 'url' => data_get($comment, 'html_url'), + 'kind' => 'pr', + 'is_bot' => str_ends_with($login, '[bot]'), + ], emit: true); + } + + /** + * @param array $payload + */ + private function push(string $repo, array $payload): void + { + foreach ($this->arr($payload, 'commits') as $commit) { + if (!is_array($commit)) { + continue; + } + + $username = $this->str($commit, 'author.username'); + $login = $username !== '' ? $username : $this->str($commit, 'author.name', 'ghost'); + + $this->recorder->execute($repo, ContributionType::Commit, 'commit:'.$this->str($commit, 'id'), $login, null, $this->str($commit, 'timestamp'), null, [ + 'url' => data_get($commit, 'url'), + 'is_bot' => str_ends_with($login, '[bot]'), + ], emit: true); + } + } + + /** + * @param array $source + */ + private function str(array $source, string $path, string $default = ''): string + { + $value = data_get($source, $path); + + return is_scalar($value) ? (string) $value : $default; + } + + /** + * @param array $source + */ + private function intOrNull(array $source, string $path): ?int + { + $value = data_get($source, $path); + + return is_numeric($value) ? (int) $value : null; + } + + /** + * @param array $source + * @return array + */ + private function arr(array $source, string $path): array + { + $value = data_get($source, $path); + + return is_array($value) ? $value : []; + } +} diff --git a/app-modules/integration-github/src/Webhook/VerifyGithubSignature.php b/app-modules/integration-github/src/Webhook/VerifyGithubSignature.php new file mode 100644 index 000000000..c0a1dc51a --- /dev/null +++ b/app-modules/integration-github/src/Webhook/VerifyGithubSignature.php @@ -0,0 +1,26 @@ +header('X-Hub-Signature-256'); + + abort_if($signature === null, 403, 'Missing X-Hub-Signature-256'); + + $secret = config()->string('services.github.webhook_secret'); + $expected = 'sha256='.hash_hmac('sha256', $request->getContent(), $secret); + + abort_unless(hash_equals($expected, $signature), 403, 'Invalid signature'); + + return $next($request); + } +} diff --git a/app-modules/integration-github/tests/Feature/BackfillGithubCommandTest.php b/app-modules/integration-github/tests/Feature/BackfillGithubCommandTest.php new file mode 100644 index 000000000..a54004ba8 --- /dev/null +++ b/app-modules/integration-github/tests/Feature/BackfillGithubCommandTest.php @@ -0,0 +1,61 @@ +instance(GitHubApiConnector::class, tap( + new GitHubApiConnector(), + fn (GitHubApiConnector $connector) => $connector->withMockClient(new MockClient(['*' => MockResponse::make([])])), + )); +} + +it('faz backfill de todos os repos habilitados e grava last_backfilled_at', function (): void { + mockEmptyGithub(); + $a = GithubRepository::factory()->create(['full_name' => 'he4rt/a']); + $b = GithubRepository::factory()->create(['full_name' => 'he4rt/b']); + $disabled = GithubRepository::factory()->disabled()->create(['full_name' => 'he4rt/c']); + + test()->artisan('github:backfill')->assertSuccessful(); + + expect($a->fresh()->last_backfilled_at)->not->toBeNull() + ->and($b->fresh()->last_backfilled_at)->not->toBeNull() + ->and($disabled->fresh()->last_backfilled_at)->toBeNull(); +}); + +it('faz backfill apenas do repo passado como argumento', function (): void { + mockEmptyGithub(); + $a = GithubRepository::factory()->create(['full_name' => 'he4rt/a']); + $b = GithubRepository::factory()->create(['full_name' => 'he4rt/b']); + + test()->artisan('github:backfill', ['repo' => 'he4rt/a'])->assertSuccessful(); + + expect($a->fresh()->last_backfilled_at)->not->toBeNull() + ->and($b->fresh()->last_backfilled_at)->toBeNull(); +}); + +it('para com mensagem clara ao bater rate limit, sem marcar last_backfilled_at', function (): void { + app()->instance(GitHubApiConnector::class, tap( + new GitHubApiConnector(), + fn (GitHubApiConnector $connector) => $connector->withMockClient(new MockClient([ + '*' => MockResponse::make( + ['message' => 'API rate limit exceeded'], + 403, + ['X-RateLimit-Remaining' => '0', 'X-RateLimit-Reset' => '1900000000'], + ), + ])), + )); + + $repo = GithubRepository::factory()->create(['full_name' => 'he4rt/a']); + + test()->artisan('github:backfill') + ->expectsOutputToContain('Rate limit') + ->assertFailed(); + + expect($repo->fresh()->last_backfilled_at)->toBeNull(); +}); diff --git a/app-modules/integration-github/tests/Feature/BackfillRepositoryTest.php b/app-modules/integration-github/tests/Feature/BackfillRepositoryTest.php new file mode 100644 index 000000000..89d447406 --- /dev/null +++ b/app-modules/integration-github/tests/Feature/BackfillRepositoryTest.php @@ -0,0 +1,200 @@ + $overrides + */ +function mockGithub(array $overrides = []): void +{ + $defaults = [ + ListPullRequests::class => MockResponse::make([]), + GetPullRequest::class => MockResponse::make([]), + ListPullRequestReviews::class => MockResponse::make([]), + ListIssues::class => MockResponse::make([]), + ListIssueComments::class => MockResponse::make([]), + ListPullRequestReviewComments::class => MockResponse::make([]), + ListCommits::class => MockResponse::make([]), + ]; + + app()->instance(GitHubApiConnector::class, tap( + new GitHubApiConnector(), + fn (GitHubApiConnector $connector) => $connector->withMockClient(new MockClient([...$defaults, ...$overrides])), + )); +} + +/** + * @return array + */ +function prPayload(int $number, string $login, int $id, ?string $merged = null): array +{ + return [ + 'number' => $number, + 'title' => 'feat: pr '.$number, + 'state' => 'open', + 'created_at' => '2026-06-01T12:00:00Z', + 'merged_at' => $merged, + 'html_url' => 'https://github.com/he4rt/heartdevs.com/pull/'.$number, + 'user' => ['login' => $login, 'id' => $id], + ]; +} + +function backfill(): void +{ + resolve(BackfillRepository::class)->execute('he4rt/heartdevs.com'); +} + +it('faz backfill de PRs upsertando contributions com tamanho e autor', function (): void { + mockGithub([ + ListPullRequests::class => MockResponse::make([prPayload(1, 'maria', 42)]), + GetPullRequest::class => MockResponse::make(['additions' => 10, 'deletions' => 2, 'changed_files' => 3]), + ]); + + backfill(); + + $contribution = GithubContribution::query()->where('external_ref', 'pr:1')->sole(); + + expect($contribution->type)->toBe(ContributionType::Pr) + ->and($contribution->actor_login)->toBe('maria') + ->and($contribution->actor_id)->toBe(42) + ->and($contribution->repo)->toBe('he4rt/heartdevs.com') + ->and($contribution->occurred_at->toIso8601String())->toBe('2026-06-01T12:00:00+00:00') + ->and($contribution->metadata['additions'])->toBe(10) + ->and($contribution->metadata['deletions'])->toBe(2) + ->and($contribution->metadata['is_bot'])->toBeFalse(); +}); + +it('é idempotente: re-rodar o backfill não duplica', function (): void { + mockGithub([ + ListPullRequests::class => MockResponse::make([prPayload(1, 'maria', 42)]), + GetPullRequest::class => MockResponse::make(['additions' => 10, 'deletions' => 2, 'changed_files' => 3]), + ]); + + backfill(); + backfill(); + + expect(GithubContribution::query()->where('external_ref', 'pr:1')->count())->toBe(1); +}); + +it('marca is_bot para autores [bot]', function (): void { + mockGithub([ + ListPullRequests::class => MockResponse::make([prPayload(7, 'dependabot[bot]', 99)]), + GetPullRequest::class => MockResponse::make(['additions' => 1, 'deletions' => 0, 'changed_files' => 1]), + ]); + + backfill(); + + expect(GithubContribution::query()->where('external_ref', 'pr:7')->sole()->metadata['is_bot'])->toBeTrue(); +}); + +it('faz backfill das reviews de um PR com target_ref para o PR', function (): void { + mockGithub([ + ListPullRequests::class => MockResponse::make([prPayload(1, 'maria', 42)]), + GetPullRequest::class => MockResponse::make(['additions' => 1, 'deletions' => 0, 'changed_files' => 1]), + ListPullRequestReviews::class => MockResponse::make([ + ['id' => 555, 'state' => 'APPROVED', 'submitted_at' => '2026-06-02T09:00:00Z', 'user' => ['login' => 'joao', 'id' => 7]], + ]), + ]); + + backfill(); + + $review = GithubContribution::query()->where('type', ContributionType::Review)->sole(); + + expect($review->external_ref)->toBe('review:555') + ->and($review->target_ref)->toBe('pr:1') + ->and($review->actor_login)->toBe('joao') + ->and($review->occurred_at->toIso8601String())->toBe('2026-06-02T09:00:00+00:00'); +}); + +it('faz backfill de issues ignorando os PRs retornados pelo endpoint de issues', function (): void { + mockGithub([ + ListIssues::class => MockResponse::make([ + ['number' => 10, 'title' => 'bug: x', 'state' => 'open', 'created_at' => '2026-06-01T00:00:00Z', 'html_url' => 'u', 'user' => ['login' => 'ana', 'id' => 3]], + ['number' => 11, 'title' => 'na verdade um PR', 'pull_request' => ['url' => 'x'], 'created_at' => '2026-06-01T00:00:00Z', 'html_url' => 'u', 'user' => ['login' => 'x', 'id' => 1]], + ]), + ]); + + backfill(); + + expect(GithubContribution::query()->where('type', ContributionType::Issue)->pluck('external_ref')->all()) + ->toBe(['issue:10']); +}); + +it('faz backfill de comentários de issue com target_ref derivado da issue_url', function (): void { + mockGithub([ + ListIssueComments::class => MockResponse::make([ + ['id' => 900, 'created_at' => '2026-06-03T10:00:00Z', 'html_url' => 'u', 'issue_url' => 'https://api.github.com/repos/he4rt/heartdevs.com/issues/10', 'user' => ['login' => 'ana', 'id' => 3]], + ]), + ]); + + backfill(); + + $comment = GithubContribution::query()->where('type', ContributionType::Comment)->sole(); + + expect($comment->external_ref)->toBe('comment:900') + ->and($comment->target_ref)->toBe('issue:10') + ->and($comment->actor_login)->toBe('ana'); +}); + +it('faz backfill de comentários de review de PR com target_ref para o PR', function (): void { + mockGithub([ + ListPullRequestReviewComments::class => MockResponse::make([ + ['id' => 1200, 'created_at' => '2026-06-03T11:00:00Z', 'html_url' => 'u', 'pull_request_url' => 'https://api.github.com/repos/he4rt/heartdevs.com/pulls/12', 'user' => ['login' => 'joao', 'id' => 7]], + ]), + ]); + + backfill(); + + $comment = GithubContribution::query()->where('external_ref', 'review_comment:1200')->sole(); + + expect($comment->type)->toBe(ContributionType::Comment) + ->and($comment->target_ref)->toBe('pr:12'); +}); + +it('propaga o erro do GitHub em vez de processar a resposta de falha como dados', function (): void { + mockGithub([ + ListPullRequests::class => MockResponse::make(['message' => 'Internal Server Error'], 500), + ]); + + expect(fn (): mixed => resolve(BackfillRepository::class)->execute('he4rt/heartdevs.com')) + ->toThrow(RequestException::class); +}); + +it('faz backfill de commits dedup por sha, com fallback de autor', function (): void { + mockGithub([ + ListCommits::class => MockResponse::make([ + ['sha' => 'abc123', 'html_url' => 'u', 'commit' => ['author' => ['name' => 'Maria', 'date' => '2026-06-04T08:00:00Z']], 'author' => ['login' => 'maria', 'id' => 42]], + ['sha' => 'def456', 'html_url' => 'u', 'commit' => ['author' => ['name' => 'Sem Conta', 'date' => '2026-06-04T09:00:00Z']], 'author' => null], + ]), + ]); + + backfill(); + + $linked = GithubContribution::query()->where('external_ref', 'commit:abc123')->sole(); + $unlinked = GithubContribution::query()->where('external_ref', 'commit:def456')->sole(); + + expect($linked->type)->toBe(ContributionType::Commit) + ->and($linked->actor_login)->toBe('maria') + ->and($linked->actor_id)->toBe(42) + ->and($linked->occurred_at->toIso8601String())->toBe('2026-06-04T08:00:00+00:00') + ->and($unlinked->actor_login)->toBe('Sem Conta') + ->and($unlinked->actor_id)->toBeNull(); +}); diff --git a/app-modules/integration-github/tests/Feature/GithubContributionTest.php b/app-modules/integration-github/tests/Feature/GithubContributionTest.php new file mode 100644 index 000000000..918e4ab69 --- /dev/null +++ b/app-modules/integration-github/tests/Feature/GithubContributionTest.php @@ -0,0 +1,47 @@ +create([ + 'repo' => 'he4rt/heartdevs.com', + 'type' => ContributionType::Pr, + 'external_ref' => 'pr:1', + 'occurred_at' => '2026-06-01T12:00:00Z', + 'metadata' => ['title' => 'feat: x', 'additions' => 10], + ]); + + expect($contribution->type)->toBe(ContributionType::Pr) + ->and($contribution->occurred_at)->toBeInstanceOf(Carbon::class) + ->and($contribution->metadata['additions'])->toBe(10) + ->and($contribution->id)->toBeString(); +}); + +it('impede contribuição duplicada por (repo, type, external_ref)', function (): void { + GithubContribution::factory()->create([ + 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', + ]); + + GithubContribution::factory()->create([ + 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', + ]); +})->throws(QueryException::class); + +it('permite o mesmo external_ref em repos ou tipos diferentes', function (): void { + GithubContribution::factory()->create([ + 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', + ]); + GithubContribution::factory()->create([ + 'repo' => 'he4rt/4noobs', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', + ]); + GithubContribution::factory()->create([ + 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Issue, 'external_ref' => 'pr:1', + ]); + + expect(GithubContribution::query()->count())->toBe(3); +}); diff --git a/app-modules/integration-github/tests/Feature/GithubRepositoryTest.php b/app-modules/integration-github/tests/Feature/GithubRepositoryTest.php new file mode 100644 index 000000000..83037dce1 --- /dev/null +++ b/app-modules/integration-github/tests/Feature/GithubRepositoryTest.php @@ -0,0 +1,28 @@ +create(['full_name' => 'he4rt/heartdevs.com']); + + expect($repo->full_name)->toBe('he4rt/heartdevs.com') + ->and($repo->enabled)->toBeTrue() + ->and($repo->last_backfilled_at)->toBeNull() + ->and($repo->id)->toBeString(); +}); + +it('impede full_name duplicado pela unique', function (): void { + GithubRepository::factory()->create(['full_name' => 'he4rt/4noobs']); + + GithubRepository::factory()->create(['full_name' => 'he4rt/4noobs']); +})->throws(QueryException::class); + +it('o scope enabled retorna somente os repositórios habilitados', function (): void { + GithubRepository::factory()->count(2)->create(); + GithubRepository::factory()->disabled()->create(); + + expect(GithubRepository::query()->enabled()->count())->toBe(2); +}); diff --git a/app-modules/integration-github/tests/Feature/GithubWebhookTest.php b/app-modules/integration-github/tests/Feature/GithubWebhookTest.php new file mode 100644 index 000000000..86baff7ca --- /dev/null +++ b/app-modules/integration-github/tests/Feature/GithubWebhookTest.php @@ -0,0 +1,116 @@ + 'test-secret']); +}); + +/** + * @param array $payload + */ +function postGithubWebhook(string $event, array $payload, ?string $delivery = null, string $secret = 'test-secret'): TestResponse +{ + $body = json_encode($payload, JSON_THROW_ON_ERROR); + $signature = 'sha256='.hash_hmac('sha256', $body, $secret); + + return test()->postJson('/api/webhooks/github', $payload, [ + 'X-GitHub-Event' => $event, + 'X-GitHub-Delivery' => $delivery ?? Str::uuid()->toString(), + 'X-Hub-Signature-256' => $signature, + ]); +} + +/** + * @return array + */ +function prWebhookPayload(string $repo = 'he4rt/heartdevs.com', int $number = 1, string $login = 'maria', int $id = 42): array +{ + return [ + 'action' => 'opened', + 'repository' => ['full_name' => $repo], + 'sender' => ['login' => $login, 'id' => $id], + 'pull_request' => [ + 'number' => $number, 'title' => 'feat: x', 'state' => 'open', 'merged_at' => null, + 'created_at' => '2026-06-01T12:00:00Z', 'html_url' => 'u', + 'additions' => 5, 'deletions' => 1, 'changed_files' => 2, + 'user' => ['login' => $login, 'id' => $id], + ], + ]; +} + +it('rejeita assinatura inválida com 403 e não grava nada', function (): void { + GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']); + + postGithubWebhook('pull_request', prWebhookPayload(), secret: 'wrong-secret') + ->assertForbidden(); + + expect(GithubEventLog::query()->count())->toBe(0) + ->and(GithubContribution::query()->count())->toBe(0); +}); + +it('grava no lake e projeta a contribuição para repo na allowlist, emitindo o evento', function (): void { + Event::fake([GithubContributionRecorded::class]); + GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']); + + postGithubWebhook('pull_request', prWebhookPayload())->assertSuccessful(); + + expect(GithubEventLog::query()->count())->toBe(1); + + $contribution = GithubContribution::query()->where('external_ref', 'pr:1')->sole(); + + expect($contribution->type)->toBe(ContributionType::Pr) + ->and($contribution->actor_login)->toBe('maria') + ->and($contribution->metadata['additions'])->toBe(5); + + Event::assertDispatched(GithubContributionRecorded::class); +}); + +it('deduplica entregas repetidas pelo delivery id', function (): void { + GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']); + $delivery = Str::uuid()->toString(); + + postGithubWebhook('pull_request', prWebhookPayload(), delivery: $delivery)->assertSuccessful(); + postGithubWebhook('pull_request', prWebhookPayload(), delivery: $delivery)->assertSuccessful(); + + expect(GithubEventLog::query()->count())->toBe(1) + ->and(GithubContribution::query()->where('external_ref', 'pr:1')->count())->toBe(1); +}); + +it('grava no lake mas NÃO projeta para repo fora da allowlist', function (): void { + postGithubWebhook('pull_request', prWebhookPayload('he4rt/secret-repo'))->assertSuccessful(); + + expect(GithubEventLog::query()->count())->toBe(1) + ->and(GithubContribution::query()->count())->toBe(0); +}); + +it('projeta issues e pushes (commits)', function (): void { + GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']); + + postGithubWebhook('issues', [ + 'action' => 'opened', + 'repository' => ['full_name' => 'he4rt/heartdevs.com'], + 'sender' => ['login' => 'ana', 'id' => 3], + 'issue' => ['number' => 10, 'title' => 'bug', 'state' => 'open', 'created_at' => '2026-06-01T00:00:00Z', 'html_url' => 'u', 'user' => ['login' => 'ana', 'id' => 3]], + ])->assertSuccessful(); + + postGithubWebhook('push', [ + 'repository' => ['full_name' => 'he4rt/heartdevs.com'], + 'sender' => ['login' => 'maria', 'id' => 42], + 'commits' => [ + ['id' => 'sha1', 'url' => 'u', 'timestamp' => '2026-06-04T08:00:00Z', 'author' => ['username' => 'maria', 'name' => 'Maria']], + ], + ])->assertSuccessful(); + + expect(GithubContribution::query()->where('external_ref', 'issue:10')->exists())->toBeTrue() + ->and(GithubContribution::query()->where('external_ref', 'commit:sha1')->exists())->toBeTrue(); +}); diff --git a/app-modules/panel-admin/src/Github/GithubCluster.php b/app-modules/panel-admin/src/Github/GithubCluster.php new file mode 100644 index 000000000..d18638944 --- /dev/null +++ b/app-modules/panel-admin/src/Github/GithubCluster.php @@ -0,0 +1,30 @@ +components([ + TextInput::make('full_name') + ->label('Repositório (owner/repo)') + ->placeholder('he4rt/heartdevs.com') + ->required() + ->maxLength(255) + ->rule('regex:/^[\w.-]+\/[\w.-]+$/') + ->unique(ignoreRecord: true), + Toggle::make('enabled') + ->label('Habilitado') + ->default(true), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('created_at', 'desc') + ->columns([ + ToggleColumn::make('enabled'), + TextColumn::make('full_name') + ->label('Repositório') + ->searchable() + ->sortable(), + TextColumn::make('last_backfilled_at') + ->label('Último backfill') + ->dateTime() + ->placeholder('nunca') + ->sortable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->recordActions([ + self::backfillAction(), + EditAction::make(), + ]); + } + + public static function backfillAction(): Action + { + return Action::make('backfill') + ->label('Backfill agora') + ->icon(Heroicon::OutlinedArrowPath) + ->color('gray') + ->requiresConfirmation() + ->action(function (GithubRepository $record): void { + try { + resolve(BackfillRepository::class)->execute($record->full_name); + } catch (RequestException $requestException) { + if (RateLimit::matches($requestException)) { + Notification::make() + ->danger() + ->title('Rate limit do GitHub atingido') + ->body('Os dados já coletados foram salvos; rode novamente após o reset'.RateLimit::resetHint($requestException).'.') + ->send(); + + return; + } + + Notification::make() + ->danger() + ->title('Falha no backfill') + ->body($requestException->getMessage()) + ->send(); + + return; + } + + $record->update(['last_backfilled_at' => Date::now()]); + + Notification::make() + ->success() + ->title('Backfill concluído para '.$record->full_name) + ->send(); + }); + } + + public static function getPages(): array + { + return [ + 'index' => ListGithubRepositories::route('/'), + 'create' => CreateGithubRepository::route('/create'), + 'edit' => EditGithubRepository::route('/{record}/edit'), + ]; + } +} diff --git a/app-modules/panel-admin/src/Github/Resources/GithubRepositoryResource/Pages/CreateGithubRepository.php b/app-modules/panel-admin/src/Github/Resources/GithubRepositoryResource/Pages/CreateGithubRepository.php new file mode 100644 index 000000000..e134742fc --- /dev/null +++ b/app-modules/panel-admin/src/Github/Resources/GithubRepositoryResource/Pages/CreateGithubRepository.php @@ -0,0 +1,13 @@ +pages([ModerationCluster::class, MarketingCluster::class, TwitchCluster::class]) + ->pages([ModerationCluster::class, MarketingCluster::class, TwitchCluster::class, GithubCluster::class]) ->navigation($this->buildNavigation(...)) ->resources([ ExternalIdentityResource::class, @@ -54,6 +55,10 @@ public function register(): void ->discoverPages( in: __DIR__.'/Twitch/Pages', for: 'He4rt\\PanelAdmin\\Twitch\\Pages', + ) + ->discoverResources( + in: __DIR__.'/Github/Resources', + for: 'He4rt\\PanelAdmin\\Github\\Resources', ); }); } @@ -103,6 +108,7 @@ private function defaultNavigation(NavigationBuilder $builder): NavigationBuilde ...ModerationCluster::getNavigationItems(), ...MarketingCluster::getNavigationItems(), ...TwitchCluster::getNavigationItems(), + ...GithubCluster::getNavigationItems(), ...ExternalIdentityResource::getNavigationItems(), ]); } diff --git a/app-modules/panel-admin/tests/Feature/Github/GithubRepositoryResourceTest.php b/app-modules/panel-admin/tests/Feature/Github/GithubRepositoryResourceTest.php new file mode 100644 index 000000000..bbe4283e8 --- /dev/null +++ b/app-modules/panel-admin/tests/Feature/Github/GithubRepositoryResourceTest.php @@ -0,0 +1,90 @@ +instance(GitHubApiConnector::class, tap( + new GitHubApiConnector(), + fn (GitHubApiConnector $connector) => $connector->withMockClient(new MockClient(['*' => $response])), + )); +} + +beforeEach(function (): void { + $user = User::factory()->create(['username' => 'danielhe4rt']); + $tenant = Tenant::factory()->create(['slug' => 'he4rt-dev']); + $tenant->members()->attach($user); + + config(['he4rt.admins' => 'danielhe4rt']); + + $this->actingAs($user); + + Filament::setCurrentPanel(Filament::getPanel('admin')); + Filament::setTenant($tenant); +}); + +test('admin cria um repositório da allowlist', function (): void { + livewire(CreateGithubRepository::class) + ->fillForm(['full_name' => 'he4rt/heartdevs.com', 'enabled' => true]) + ->call('create') + ->assertHasNoFormErrors(); + + expect(GithubRepository::query()->where('full_name', 'he4rt/heartdevs.com')->exists())->toBeTrue(); +}); + +test('rejeita full_name sem owner/repo', function (): void { + livewire(CreateGithubRepository::class) + ->fillForm(['full_name' => '4noobs']) + ->call('create') + ->assertHasFormErrors(['full_name']); +}); + +test('rejeita full_name duplicado', function (): void { + GithubRepository::factory()->create(['full_name' => 'he4rt/4noobs']); + + livewire(CreateGithubRepository::class) + ->fillForm(['full_name' => 'he4rt/4noobs']) + ->call('create') + ->assertHasFormErrors(['full_name']); +}); + +test('backfill pelo painel avisa de forma amigável ao bater rate limit, sem marcar last_backfilled_at', function (): void { + mockGithubConnector(MockResponse::make( + ['message' => 'API rate limit exceeded'], + 403, + ['X-RateLimit-Remaining' => '0', 'X-RateLimit-Reset' => '1900000000'], + )); + + $repo = GithubRepository::factory()->create(['full_name' => 'he4rt/a']); + + livewire(ListGithubRepositories::class) + ->callAction(TestAction::make('backfill')->table($repo)) + ->assertNotified('Rate limit do GitHub atingido'); + + expect($repo->fresh()->last_backfilled_at)->toBeNull(); +}); + +test('backfill pelo painel avisa de falha genérica sem marcar last_backfilled_at', function (): void { + mockGithubConnector(MockResponse::make(['message' => 'Internal Server Error'], 500)); + + $repo = GithubRepository::factory()->create(['full_name' => 'he4rt/a']); + + livewire(ListGithubRepositories::class) + ->callAction(TestAction::make('backfill')->table($repo)) + ->assertNotified('Falha no backfill'); + + expect($repo->fresh()->last_backfilled_at)->toBeNull(); +}); diff --git a/app-modules/portal/resources/views/community-retrospective.blade.php b/app-modules/portal/resources/views/community-retrospective.blade.php new file mode 100644 index 000000000..58afe7d31 --- /dev/null +++ b/app-modules/portal/resources/views/community-retrospective.blade.php @@ -0,0 +1,99 @@ +
+
+
+

Quem fez a He4rt bater

+

Participação da comunidade nos repositórios públicos.

+ +
+ + + {{ $data['period']['since'] }} → {{ $data['period']['until'] }} +
+
+ + @php + $cards = [ + ['label' => 'Pessoas', 'value' => $data['meta']['people'], 'hint' => null], + [ + 'label' => 'PRs', + 'value' => $data['meta']['prs'], + 'hint' => $data['meta']['prs_merged'] . ' merged · ' . $data['meta']['prs_unmerged'] . ' fechados', + ], + ['label' => 'Reviews', 'value' => $data['meta']['reviews'], 'hint' => null], + ['label' => 'Issues', 'value' => $data['meta']['issues'], 'hint' => null], + ['label' => 'Comentários', 'value' => $data['meta']['comments'], 'hint' => null], + ['label' => 'Commits', 'value' => $data['meta']['commits'], 'hint' => null], + ]; + @endphp +
+ @foreach ($cards as $card) +
+
{{ $card['value'] }}
+
{{ $card['label'] }}
+ @if ($card['hint']) +
{{ $card['hint'] }}
+ @endif +
+ @endforeach +
+ +
+ @forelse ($data['people'] as $person) + + {{ $person['login'] }} +
+
{{ '@' . $person['login'] }}
+
+ + {{ $person['prs'] }} PRs + @if ($person['prs_unmerged'] > 0) + · {{ $person['prs_unmerged'] }} não mergeado{{ $person['prs_unmerged'] > 1 ? 's' : '' }} + @endif + + {{ $person['reviews'] }} reviews + {{ $person['issues'] }} issues + {{ $person['comments'] }} comentários + {{ $person['commits'] }} commits +
+
+
+
{{ $person['total'] }}
+
interações
+
+
+ @empty +

Ninguém bateu a He4rt nessa janela.

+ @endforelse +
+
+
diff --git a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php new file mode 100644 index 000000000..7fefbf9fa --- /dev/null +++ b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php @@ -0,0 +1,42 @@ +since !== null && $this->since !== '' + ? CarbonImmutable::parse($this->since)->startOfDay() + : CarbonImmutable::now()->startOfWeek(CarbonInterface::MONDAY)->subWeek(); + + $until = $this->until !== null && $this->until !== '' + ? CarbonImmutable::parse($this->until)->endOfDay() + : CarbonImmutable::now(); + + return view('portal::community-retrospective', [ + 'data' => new CommunityRetrospective($since, $until)->build(), + 'sinceValue' => $since->toDateString(), + 'untilValue' => $until->toDateString(), + ]); + } +} diff --git a/app-modules/portal/src/PortalServiceProvider.php b/app-modules/portal/src/PortalServiceProvider.php index 68d8a9c17..00e43c29f 100644 --- a/app-modules/portal/src/PortalServiceProvider.php +++ b/app-modules/portal/src/PortalServiceProvider.php @@ -4,6 +4,7 @@ namespace He4rt\Portal; +use He4rt\Portal\Livewire\CommunityRetrospectivePage; use He4rt\Portal\Livewire\HeroSection; use He4rt\Portal\Livewire\Homepage; use Illuminate\Support\Facades\Route; @@ -17,6 +18,7 @@ public function register(): void {} public function boot(): void { Route::get('/', Homepage::class); + Route::get('/comunidade/retrospectiva', CommunityRetrospectivePage::class)->name('community.retrospective'); Livewire::component('hero-section', HeroSection::class); } diff --git a/app-modules/portal/src/Retrospective/CommunityRetrospective.php b/app-modules/portal/src/Retrospective/CommunityRetrospective.php new file mode 100644 index 000000000..0606b0b7b --- /dev/null +++ b/app-modules/portal/src/Retrospective/CommunityRetrospective.php @@ -0,0 +1,144 @@ +, + * people: list>, + * } + */ + public function build(): array + { + /** @var Collection $contributions */ + $contributions = GithubContribution::query() + ->whereBetween('occurred_at', [$this->since, $this->until]) + ->get() + ->reject(fn (GithubContribution $contribution): bool => $this->isBot($contribution)) + ->values(); + + /** @var list> $people */ + $people = $contributions + ->groupBy('actor_login') + ->map(fn (Collection $items, string $login): array => $this->person($login, $items)) + ->sortByDesc('total') + ->values() + ->all(); + + return [ + 'period' => ['since' => $this->since->toDateString(), 'until' => $this->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), + 'commits' => $this->countType($contributions, ContributionType::Commit), + 'total' => $contributions->count(), + ], + 'people' => $people, + ]; + } + + /** + * @param Collection $items + * @return array + */ + private function person(string $login, Collection $items): array + { + $actorId = $items->first()?->actor_id; + + return [ + 'login' => $login, + 'avatar' => $actorId !== null + ? 'https://avatars.githubusercontent.com/u/'.$actorId.'?v=4' + : 'https://github.com/'.$login.'.png', + 'url' => 'https://github.com/'.$login, + 'prs' => $this->countType($items, ContributionType::Pr), + 'prs_merged' => $this->countMergedPrs($items), + 'prs_unmerged' => $this->countUnmergedPrs($items), + 'reviews' => $this->countType($items, ContributionType::Review), + 'issues' => $this->countType($items, ContributionType::Issue), + 'comments' => $this->countType($items, ContributionType::Comment), + 'commits' => $this->countType($items, ContributionType::Commit), + 'total' => $items->count(), + ]; + } + + /** + * @param Collection $items + */ + private function countType(Collection $items, ContributionType $type): int + { + return $items->filter(fn (GithubContribution $contribution): bool => $contribution->type === $type)->count(); + } + + private function isBot(GithubContribution $contribution): bool + { + $metadata = $contribution->metadata ?? []; + + return ($metadata['is_bot'] ?? false) === true + || str_ends_with($contribution->actor_login, '[bot]'); + } + + /** + * @param Collection $items + */ + private function countMergedPrs(Collection $items): int + { + return $items->filter(fn (GithubContribution $contribution): bool => $this->isMergedPr($contribution))->count(); + } + + /** + * @param Collection $items + */ + private function countUnmergedPrs(Collection $items): int + { + return $items->filter(fn (GithubContribution $contribution): bool => $this->isUnmergedClosedPr($contribution))->count(); + } + + private function isMergedPr(GithubContribution $contribution): bool + { + if ($contribution->type !== ContributionType::Pr) { + return false; + } + + $metadata = $contribution->metadata ?? []; + + return ($metadata['merged'] ?? false) === true; + } + + private function isUnmergedClosedPr(GithubContribution $contribution): bool + { + if ($contribution->type !== ContributionType::Pr) { + return false; + } + + $metadata = $contribution->metadata ?? []; + + return ($metadata['state'] ?? null) === 'closed' && ($metadata['merged'] ?? false) !== true; + } +} diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php new file mode 100644 index 000000000..5ca8590e5 --- /dev/null +++ b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php @@ -0,0 +1,50 @@ +create([ + 'actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Pr, + 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false], + ]); + + livewire(CommunityRetrospectivePage::class, ['since' => '2026-06-01', 'until' => '2026-06-07']) + ->assertOk() + ->assertSee('maria'); +}); + +it('usa a janela padrão (segunda passada → hoje) quando sem parâmetros', function (): void { + $this->travelTo(CarbonImmutable::parse('2026-06-04 10:00:00')); + + GithubContribution::factory()->create([ + 'actor_login' => 'joao', 'actor_id' => 7, 'type' => ContributionType::Issue, + 'external_ref' => 'issue:1', 'occurred_at' => '2026-06-02', + ]); + + livewire(CommunityRetrospectivePage::class) + ->assertOk() + ->assertSee('joao'); +}); + +it('responde na rota pública /comunidade/retrospectiva', function (): void { + test()->get('/comunidade/retrospectiva')->assertOk(); +}); + +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, + 'external_ref' => 'pr:5', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => false], + ]); + + livewire(CommunityRetrospectivePage::class, ['since' => '2026-06-01', 'until' => '2026-06-07']) + ->assertOk() + ->assertSee('rejeitada') + ->assertSee('não mergeado'); +}); diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php new file mode 100644 index 000000000..eb835a341 --- /dev/null +++ b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php @@ -0,0 +1,68 @@ +since = CarbonImmutable::parse('2026-06-01 00:00:00'); + $this->until = CarbonImmutable::parse('2026-06-07 23:59:59'); +}); + +it('agrega contribuições por pessoa com contagem por tipo e total, ordenado desc', function (): void { + GithubContribution::factory()->create(['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + GithubContribution::factory()->create(['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-06-03']); + GithubContribution::factory()->create(['actor_login' => 'joao', 'actor_id' => 7, 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-03']); + + $data = new CommunityRetrospective($this->since, $this->until)->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 { + GithubContribution::factory()->create(['actor_login' => 'dependabot[bot]', 'type' => ContributionType::Pr, 'external_ref' => 'pr:9', 'occurred_at' => '2026-06-02', 'metadata' => ['is_bot' => true, 'state' => 'open', 'merged' => false]]); + GithubContribution::factory()->create(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + + $data = new CommunityRetrospective($this->since, $this->until)->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 { + // a mesma pessoa com os três desfechos: fechado-sem-merge, merged e aberto + GithubContribution::factory()->create(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => false]]); + GithubContribution::factory()->create(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => true]]); + GithubContribution::factory()->create(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:3', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + + $data = new CommunityRetrospective($this->since, $this->until)->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('respeita a janela de período pelo occurred_at', function (): void { + GithubContribution::factory()->create(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-05-30']); + GithubContribution::factory()->create(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:2', 'occurred_at' => '2026-06-03']); + + $data = new CommunityRetrospective($this->since, $this->until)->build(); + + expect($data['meta']['total'])->toBe(1) + ->and($data['meta']['issues'])->toBe(1); +}); diff --git a/config/services.php b/config/services.php index 6cefcecfa..0beb6e230 100644 --- a/config/services.php +++ b/config/services.php @@ -66,6 +66,8 @@ 'client_secret' => env('GITHUB_OAUTH_CLIENT_SECRET'), 'scopes' => env('GITHUB_OAUTH_SCOPES', 'read:user user:email'), 'enabled' => env('GITHUB_OAUTH_ENABLED', true), + 'api_token' => env('GITHUB_API_TOKEN'), + 'webhook_secret' => env('GITHUB_WEBHOOK_SECRET'), ], 'openai' => [ diff --git a/docs/plans/2026-06-04-github-community-contributions.md b/docs/plans/2026-06-04-github-community-contributions.md new file mode 100644 index 000000000..5d3cde3f7 --- /dev/null +++ b/docs/plans/2026-06-04-github-community-contributions.md @@ -0,0 +1,480 @@ +# GitHub Community Contributions — Plano de implementação + +> Substitui a abordagem provisória (`app/Retrospective/*` + `community:retrospective`, que rodava `gh` ao vivo e subia JSON no waifuvault) por uma ingestão persistente, modelada dentro da arquitetura modular do projeto. +> +> Origem das decisões: sessão de grilling (2026-06-04). Base e PR: **`4.x`** (o `3.x` não tem `activity` nem `integration-github`). + +## Decisões cravadas + +| # | Decisão | Escolha | +| --- | ------------ | ----------------------------------------------------------------------------------------- | +| 1 | Público | Todos os contribuidores (por login GitHub); gamificação é enriquecimento opcional | +| 2 | Modelagem | `github_contributions` (normalizado) + `github_event_logs` (lake bruto) — espelha Discord | +| 3 | Credencial | PAT fine-grained + webhook de org manual (secret verificado) | +| 4 | Escopo repos | Allowlist gerenciável no painel admin (`github_repositories`) | +| 5 | Tipos | Core de código: PR, review, issue, comentário, commit (sem reactions) | +| 6 | Backfill | Histórico completo, incremental/resumível via `last_backfilled_at` | +| 7 | Fluxo ETL | Split: webhook→lake→ETL→contributions; backfill→upsert direto | +| 8 | Gamificação | Só a seam agora (evento `GithubContributionRecorded`) | +| 9 | Apresentação | Página pública no portal (Livewire), seletor de período, `?since=&until=` | +| 10 | Filtragem | Grava tudo, filtra na leitura (bots, PR closed-unmerged) | +| 11 | Limpeza/base | Dropar `dda959be`, base e PR no `4.x` | + +--- + +## Arquitetura macro (sistema) + +``` + ┌───────────────────────────────────────────────────────────────────┐ + │ integration-github (transport) │ + │ │ + │ Transport/ │ + │ GitHubApiConnector (+ PAT via defaultAuth) │ + │ Requests/ ListPullRequests · GetPullRequest · ListIssues · │ + │ ListReviews · ListIssueComments · ListPrComments · │ + │ ListCommits · GetUser │ + │ Backfill/ BackfillRepository (action) + console command │ + │ Webhook/ VerifyGithubSignature (mw) · GithubWebhookController │ + │ ProjectGithubEvent (ETL action) │ + │ Models/ GithubRepository · GithubContribution · GithubEventLog│ + │ Events/ GithubContributionRecorded ◄── seam p/ gamificação │ + └───────────────┬─────────────────────────────────────┬─────────────┘ + │ lê contributions │ CRUD allowlist + ▼ ▼ + ┌───────────────────────────────┐ ┌───────────────────────────────┐ + │ portal (Livewire) │ │ panel-admin (Filament) │ + │ /comunidade/retrospectiva │ │ Github/Resources/ │ + │ seletor de período │ │ GithubRepositoryResource │ + │ read-model + filtros (leitura)│ │ (+ Contribution/EventLog ro) │ + └───────────────────────────────┘ └───────────────────────────────┘ +``` + +### Regras de dependência (a registrar no CONTEXT-MAP.md) + +- `integration-github` é **transport**: não depende de `activity`, `economy` nem `moderation`. +- `integration-github` depende de `identity` **apenas** para a seam futura (resolver `Character` via `ExternalIdentity`) — hoje só emite o evento `GithubContributionRecorded`, sem importar `activity`. +- `panel-admin` e `portal` dependem de `integration-github` (consomem os modelos). Nunca o contrário. + +--- + +## Modelo de dados + +``` +github_repositories ── allowlist editável no painel + id uuid pk + full_name string "he4rt/heartdevs.com" (unique) + enabled bool default true + last_backfilled_at timestamp null + timestamps + +github_contributions ── read-model da apresentação + id uuid pk + repo string (index) "he4rt/heartdevs.com" + actor_login string "@maria" + actor_id bigint null (index) perfil GitHub estável a rename + type string (index) pr | review | issue | comment | commit + external_ref string "pr:123" "review:456" "commit:" + target_ref string null a qual PR/issue pertence ("pr:123") + occurred_at timestamp (index) + metadata jsonb null title, additions, deletions, files, + url, state, merged, is_bot, avatar, name + timestamps + unique (repo, type, external_ref) ── idempotência backfill+webhook + +github_event_logs ── lake bruto, só webhook (replay/auditoria) + id bigint pk + event_type string (index) "pull_request" "push" ... + repo string null (index) + actor_login string null + delivery_id string (index, unique) X-GitHub-Delivery (dedup) + payload jsonb + timestamps +``` + +### Esquema de `external_ref` / `target_ref` + +| type | external_ref | target_ref | fonte do `occurred_at` | +| ------- | ---------------- | ---------------------- | ---------------------- | +| pr | `pr:{number}` | — | `created_at` do PR | +| review | `review:{id}` | `pr:{number}` | `submitted_at` | +| issue | `issue:{number}` | — | `created_at` da issue | +| comment | `comment:{id}` | `pr:{n}`/`issue:{n}` | `created_at` | +| commit | `commit:{sha}` | `pr:{n}` (se via push) | `commit.author.date` | + +--- + +## Fatia 0 — Limpeza + base no 4.x + +**Contexto.** A branch `feat/community-retrospective` é `origin/4.x` (`c5c45e07`) + um único commit provisório `dda959be`, que adicionou `app/Retrospective/*`, `app/Console/Commands/RetrospectiveCommand.php`, registro no `app/Providers/AppServiceProvider.php` (linhas 10 e 30‑33), testes e docs de design. A decisão #11 é apagar tudo isso e recomeçar do `4.x` limpo. Como `dda959be` é o HEAD e não há nada depois dele, basta resetar a branch para `origin/4.x`. A branch ainda não tem PR aberto; se já estiver no remoto, o reset exige `--force-with-lease`. + +**Comportamento esperado.** + +``` +Given a branch feat/community-retrospective = origin/4.x + dda959be +When reseto a branch para origin/4.x +Then app/Retrospective/, RetrospectiveCommand, docs/retrospectivas e os planos + provisórios somem do working tree +And app/Providers/AppServiceProvider.php volta ao estado do 4.x (sem o bind + de RetrospectiveHistory e sem o import) +And git diff origin/4.x..HEAD fica vazio (base limpa para as próximas fatias) +``` + +- Edge: se a branch já foi pushada → `git push --force-with-lease`. Se houver stash/WIP não commitado → abortar e avisar antes. +- Backward compat: nenhuma — o command provisório nunca foi mergeado no `4.x`. + +**Antes** (`app/Providers/AppServiceProvider.php`): + +```php +use App\Retrospective\RetrospectiveHistory; +// ... +$this->app->bind( + RetrospectiveHistory::class, + fn(): RetrospectiveHistory => new RetrospectiveHistory(base_path('docs/retrospectivas/README.md')), +); +``` + +**Depois:** linhas removidas (volta ao `AppServiceProvider` do `4.x`). + +Operação: `git reset --hard origin/4.x` (executar só após aprovação do plano). + +--- + +## Fatia 1 — Fundação do módulo: allowlist + admin + docs + +**Contexto.** O `integration-github` hoje só tem OAuth (`GetCurrentUser`) e um `GitHubApiConnector` **sem autenticação**, e **não tem `CONTEXT.md`**. Esta fatia entrega a menor vertical completa e útil sozinha: cadastrar/editar quais repos contam, pelo painel admin. Cria a tabela `github_repositories`, o modelo, o `GithubRepositoryResource` no `panel-admin` (espelhando `Twitch/Resources/`), e a documentação do módulo (`CONTEXT.md` + ADR) que registra as 11 decisões. + +**Comportamento esperado.** + +``` +# Happy path +Given um admin logado no panel-admin +When ele cria um repositório "he4rt/4noobs" e marca enabled +Then surge uma linha em github_repositories com enabled=true, last_backfilled_at=null +And ela passa a ser candidata a backfill e a filtro de webhook + +# Edge — duplicado +Given já existe "he4rt/4noobs" +When tentam criar de novo +Then a unique(full_name) barra e o Filament mostra erro de validação + +# Edge — formato inválido +When informam "4noobs" (sem owner) +Then validação "owner/repo" rejeita antes de salvar + +# Backward compat +Given nenhuma allowlist cadastrada +Then backfill e webhook simplesmente não processam nada (no-op seguro) +``` + +**Depois** (migration nova `…_create_github_repositories_table.php`): + +```php +Schema::create('github_repositories', function (Blueprint $table): void { + $table->uuid('id')->primary(); + $table->string('full_name')->unique(); // owner/repo + $table->boolean('enabled')->default(true); + $table->timestamp('last_backfilled_at')->nullable(); + $table->timestamps(); +}); +``` + +**Depois** (`integration-github/src/Models/GithubRepository.php`): + +```php +/** + * @property string $id + * @property string $full_name + * @property bool $enabled + * @property Carbon|null $last_backfilled_at + */ +final class GithubRepository extends Model +{ + use HasUuids; + + protected function casts(): array + { + return ['enabled' => 'boolean', 'last_backfilled_at' => 'datetime']; + } + + /** @return Builder */ + public function scopeEnabled(Builder $query): Builder + { + return $query->where('enabled', true); + } +} +``` + +**Depois** (`panel-admin/src/Github/Resources/GithubRepositoryResource.php` — esqueleto, espelhando `TwitchSubscriptionResource`): + +```php +public static function form(Schema $schema): Schema +{ + return $schema->components([ + TextInput::make('full_name') + ->required()->unique(ignoreRecord: true) + ->rule('regex:/^[\w.-]+\/[\w.-]+$/'), // owner/repo + Toggle::make('enabled')->default(true), + ]); +} +// table(): full_name, enabled (toggle), last_backfilled_at, ação "Backfill agora" +``` + +CONTEXT.md/ADR: criar `integration-github/CONTEXT.md` (estilo `integration-discord`) + `integration-github/docs/adr/0001-github-community-contributions.md` registrando as decisões; atualizar `CONTEXT-MAP.md`. + +--- + +## Fatia 2 — Auth PAT + store de contribuições + backfill de PRs (tracer bullet) + +**Contexto.** Primeira vertical de dados ponta-a-ponta: autenticar o `GitHubApiConnector` com o PAT, criar a tabela/modelo `github_contributions`, e um backfill que lista os PRs de **um** repo da allowlist e faz upsert idempotente. Escolhemos PR como tracer porque exercita paginação, dedup e o enriquecimento de tamanho (additions/deletions/files) — que exige uma sub-chamada `GET /repos/{repo}/pulls/{n}` por PR (a list endpoint não traz esses campos, como o provisório já fazia via `gh pr view`). + +**Antes** (`GitHubApiConnector` — sem auth): + +```php +final class GitHubApiConnector extends Connector +{ + use HasTimeout; + public function resolveBaseUrl(): string + { + return 'https://api.github.com'; + } +} +``` + +**Depois** (PAT via `defaultAuth`, mesmo padrão de `GetCurrentUser`): + +```php +final class GitHubApiConnector extends Connector +{ + use HasTimeout; + public function resolveBaseUrl(): string + { + return 'https://api.github.com'; + } + + protected function defaultAuth(): ?TokenAuthenticator + { + $token = config('services.github.api_token'); + return $token ? new TokenAuthenticator($token) : null; + } +} +``` + +**Depois** (`config/services.php`): + +```php +'github' => [ + // ...OAuth existente... + 'api_token' => env('GITHUB_API_TOKEN'), // PAT fine-grained (backfill) + 'webhook_secret' => env('GITHUB_WEBHOOK_SECRET'), // usado na Fatia 3 +], +``` + +**Depois** (migration `…_create_github_contributions_table.php`): conforme o [modelo de dados](#modelo-de-dados), com `unique(['repo','type','external_ref'])` e índices em `repo`, `actor_id`, `type`, `occurred_at`. + +**Depois** (`Backfill/BackfillRepository.php` — núcleo do upsert): + +```php +GithubContribution::query()->updateOrCreate( + ['repo' => $repo, 'type' => 'pr', 'external_ref' => "pr:{$pr['number']}"], + [ + 'actor_login' => $pr['user']['login'], + 'actor_id' => $pr['user']['id'] ?? null, + 'occurred_at' => $pr['created_at'], + 'metadata' => [ + 'title' => $pr['title'], + 'state' => $pr['state'], + 'merged' => $pr['merged_at'] !== null, + 'url' => $pr['html_url'], + 'is_bot' => str_ends_with($pr['user']['login'], '[bot]'), + 'additions' => $detail['additions'], + 'deletions' => $detail['deletions'], + 'changed_files' => $detail['changed_files'], + ], + ], +); +``` + +**Comportamento esperado.** + +``` +# Happy path +Given "he4rt/heartdevs.com" enabled e GITHUB_API_TOKEN configurado +When rodo o backfill de PRs do repo +Then cada PR vira 1 linha em github_contributions (type=pr) com tamanho no metadata + +# Idempotência (re-run) +When rodo o backfill de novo +Then updateOrCreate atualiza as mesmas linhas (0 duplicadas), graças à unique + +# Edge — PR de bot +Given um PR aberto por dependabot[bot] +Then a linha é gravada com metadata.is_bot=true (filtragem fica na leitura) + +# Edge — sem token +Given GITHUB_API_TOKEN ausente +Then defaultAuth retorna null, a API responde 401/rate-limit e o command falha + com mensagem clara (não grava lixo) + +# Edge — rate limit (403/429) +Then respeita Retry-After / X-RateLimit-Reset e continua de onde parou +``` + +Depois desta fatia, replicar o mesmo padrão de Request+upsert para **issues, reviews, comentários e commits** (cada um com seu `external_ref`/`target_ref`). + +--- + +## Fatia 3 — Webhook ao vivo (lake + ETL + seam) + +**Contexto.** Captura das mudanças (crítico #3). Espelha o Twitch EventSub: rota `routes/github-webhook-routes.php` (auto-carregada pelo `internachi/modular`), middleware `VerifyGithubSignature` (HMAC `X-Hub-Signature-256`), controller que grava o payload bruto em `github_event_logs` (dedup por `X-GitHub-Delivery`) e dispara `ProjectGithubEvent`, que filtra pela allowlist e faz o mesmo upsert da Fatia 2, emitindo `GithubContributionRecorded` ao final. + +``` + GitHub org ──POST──► VerifyGithubSignature ──► GithubWebhookController + (assinado) (HMAC sha256, secret) │ grava lake (delivery_id) + ▼ + ProjectGithubEvent (ETL) + ├─ repo ∈ allowlist? senão ignora + ├─ mapeia event_type → type/ref + ├─ upsert github_contributions + └─ event GithubContributionRecorded +``` + +**Antes** (Twitch — referência de assinatura): + +```php +$expectedSignature = 'sha256=' . hash_hmac('sha256', $hmacMessage, $secret); +abort_unless(hash_equals($expectedSignature, $signature), 403, 'Invalid signature'); +``` + +**Depois** (`VerifyGithubSignature`): + +```php +$signature = $request->header('X-Hub-Signature-256'); +abort_if(!$signature, 403, 'Missing X-Hub-Signature-256'); +$secret = config()->string('services.github.webhook_secret'); +$expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), $secret); +abort_unless(hash_equals($expected, $signature), 403, 'Invalid signature'); +``` + +**Depois** (`routes/github-webhook-routes.php`): + +```php +Route::prefix('api/webhooks/github') + ->middleware([VerifyGithubSignature::class]) + ->group(fn() => Route::post('/', GithubWebhookController::class)->name('github.webhook')); +``` + +**Comportamento esperado.** + +``` +# Happy path +Given webhook de org configurado com o secret correto +When alguém abre um PR em repo da allowlist +Then github_event_logs ganha 1 linha (payload bruto, delivery_id) +And github_contributions ganha/atualiza a linha pr:{n} +And GithubContributionRecorded é emitido + +# Dedup de entrega +Given o GitHub reenvia a mesma entrega (mesmo X-GitHub-Delivery) +Then a unique(delivery_id) evita reprocessar (idempotente) + +# Convergência backfill↔webhook +Given pr:42 já veio do backfill +When chega o webhook pull_request.synchronize do pr:42 +Then o upsert atualiza a MESMA linha (sem duplicar), pela unique(repo,type,ref) + +# Edge — repo fora da allowlist +Then grava no lake (auditoria) mas NÃO projeta para contributions + +# Edge — assinatura inválida / secret errado +Then 403 e nada é gravado + +# Reactions +Then não há evento de webhook; permanecem fora de escopo (decisão #5) +``` + +Eventos assinados: `pull_request`, `pull_request_review`, `issues`, `issue_comment`, `push`. + +--- + +## Fatia 4 — Backfill completo e resumível (todos os tipos, todos os repos) + +**Contexto.** Consolida a Fatia 2 num command que percorre **todos** os repos `enabled`, faz backfill de **todos** os tipos desde a criação do repo, trata paginação e rate limit, e grava `last_backfilled_at`. Re-rodar é seguro (idempotente) e incremental (usa `since`/`last_backfilled_at` para encurtar). + +**Comportamento esperado.** + +``` +# Happy path +When rodo `php artisan github:backfill` +Then para cada repo enabled, importa PRs/issues/reviews/comments/commits do histórico +And grava last_backfilled_at = now() ao concluir cada repo + +# Resumível +Given last_backfilled_at preenchido +When rodo de novo +Then usa `since` para buscar só o delta (menos páginas/rate limit) + +# Edge — repo único +When `php artisan github:backfill he4rt/4noobs` +Then processa só esse repo + +# Edge — rate limit no meio +Then respeita o reset e retoma; commits (mais pesados) não estouram a janela +``` + +--- + +## Fatia 5 — Apresentação no portal (Livewire) — secundária + +**Contexto.** Página pública lendo de `github_contributions`, com seletor de período e URL compartilhável. As regras de filtragem (sem bots, sem PR closed-unmerged, issue contada pelo opened) vivem **aqui, na leitura** (decisão #10). Reaproveita o design da apresentação "Quem fez a He4rt bater" (paleta `#782bf1`, anel de avatar, badges +/−). + +``` + ┌─────────────────────────────────────────────────────────┐ + │ Quem fez a He4rt bater [◄ Semana ►] [since–until]│ + ├─────────────────────────────────────────────────────────┤ + │ pessoas · PRs · reviews · issues · comentários · commits│ meta cards + ├─────────────────────────────────────────────────────────┤ + │ ┌────────┐ Top contribuidores (anel avatar, badges +/−)│ + │ │ avatar │ @login · N interações · +adds/−dels │ + │ └────────┘ lista de PRs/reviews/issues com link │ + ├─────────────────────────────────────────────────────────┤ + │ Pull Requests por frente (scope do título) │ + └─────────────────────────────────────────────────────────┘ +``` + +**Comportamento esperado.** + +``` +# Happy path +Given contribuições no período [since, until] +When acesso /comunidade/retrospectiva?since=2026-05-26&until=2026-06-01 +Then vejo meta agregada e o ranking por pessoa (occurred_at na janela) + +# Filtragem na leitura +Then bots (metadata.is_bot) são excluídos +And PR com state=closed e merged=false não conta +And issue conta pelo occurred_at (opened) + +# Edge — período vazio +Then estado vazio amigável ("ninguém bateck nessa janela") + +# Default +Given sem querystring +Then janela padrão = segunda passada → hoje (igual ao provisório) +``` + +--- + +## Estratégia de testes + +- **Fatia 1**: Pest Feature do Filament Resource (criar/duplicar/validar formato) + Unit do scope `enabled`. +- **Fatia 2**: Saloon `MockClient` para as Requests; teste de idempotência do upsert; teste de `is_bot`. +- **Fatia 3**: Feature da rota (assinatura válida/inválida), dedup por delivery, convergência backfill↔webhook, filtro de allowlist; assert no evento `GithubContributionRecorded`. +- **Fatia 4**: Unit do paginador/rate-limit (mock de headers) + `last_backfilled_at`. +- **Fatia 5**: Livewire test do seletor de período e das regras de filtragem na leitura. + +## Riscos / pontos abertos + +- **Commits sem usuário GitHub vinculado** (só email no `commit.author`): `actor_id` nulo, `actor_login` = melhor esforço (username do push ou email). Decidir se entram no ranking ou só na contagem macro. +- **Custo do enriquecimento de PR** (1 `GET /pulls/{n}` por PR): aceitável no histórico, monitorar rate limit. +- **Webhook de org cobre todos os repos**; a allowlist filtra na ETL — repos não-allowlistados ainda geram tráfego no lake (auditoria). Avaliar TTL/limpeza do lake no futuro. +- **Config de env** (`GITHUB_API_TOKEN`, `GITHUB_WEBHOOK_SECRET`) e o registro do webhook na org são passos manuais de operação (fora do código). From fb4ee60b6ee9dd4a5d86ffa827376ab5b928ba07 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Fri, 5 Jun 2026 21:59:11 -0300 Subject: [PATCH 02/27] =?UTF-8?q?refactor(github):=20isola=20contribui?= =?UTF-8?q?=C3=A7=C3=B5es=20e=20allowlist=20por=20tenant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O painel admin é multi-tenant, então tratar a allowlist e as contribuições como globais quebrava ao paginar (o model não tinha relação `tenant`). Agora cada comunidade tem o seu recorte, consistente com o resto do sistema. - github_repositories e github_contributions carregam tenant_id; as uniques passam a (tenant_id, full_name) e (tenant_id, repo, type, external_ref). - ProjectGithubEvent faz fan-out: uma entrega do webhook vira uma contribuição por tenant que acompanha o repo; o lake github_event_logs segue global. - BackfillRepository::execute recebe o GithubRepository e carimba o tenant_id do repo de origem. - A retrospectiva do portal filtra por tenant, resolvido por slug de rota (/comunidade/{tenant}/retrospectiva) com default em config('he4rt.main_tenant'). - GithubRepositoryResource volta a ser tenant-scoped; a validação de unicidade do form é escopada ao tenant atual. Contribuição de repo compartilhado é duplicada por tenant — trade-off aceito em favor do isolamento total entre comunidades. --- app-modules/integration-github/CONTEXT.md | 2 +- .../factories/GithubContributionFactory.php | 2 + .../factories/GithubRepositoryFactory.php | 2 + ...00001_create_github_repositories_table.php | 7 ++- ...0002_create_github_contributions_table.php | 9 ++- .../0001-github-community-contributions.md | 18 +++--- .../src/Backfill/BackfillRepository.php | 57 ++++++++++-------- .../src/Console/BackfillGithubCommand.php | 2 +- .../src/Contributions/RecordContribution.php | 5 +- .../src/Models/GithubContribution.php | 11 ++++ .../src/Models/GithubRepository.php | 11 ++++ .../src/Webhook/ProjectGithubEvent.php | 59 +++++++++++-------- .../tests/Feature/BackfillRepositoryTest.php | 32 ++++++---- .../tests/Feature/GithubContributionTest.php | 29 +++++++-- .../tests/Feature/GithubRepositoryTest.php | 18 ++++-- .../tests/Feature/GithubWebhookTest.php | 15 ++++- .../Resources/GithubRepositoryResource.php | 8 ++- .../Github/GithubRepositoryResourceTest.php | 43 ++++++++++---- .../Livewire/CommunityRetrospectivePage.php | 18 +++++- .../portal/src/PortalServiceProvider.php | 1 + .../Retrospective/CommunityRetrospective.php | 2 + .../CommunityRetrospectivePageTest.php | 18 ++++-- .../Feature/CommunityRetrospectiveTest.php | 49 ++++++++++----- config/he4rt.php | 1 + 24 files changed, 297 insertions(+), 122 deletions(-) diff --git a/app-modules/integration-github/CONTEXT.md b/app-modules/integration-github/CONTEXT.md index 583ce00c2..fc77a6161 100644 --- a/app-modules/integration-github/CONTEXT.md +++ b/app-modules/integration-github/CONTEXT.md @@ -8,7 +8,7 @@ Transport and integration layer for the GitHub platform. Owns all HTTP communica | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | **Transport** | The Saloon-based HTTP layer (`Transport/`) that sends requests to GitHub's REST API. All GitHub HTTP goes through here, authenticated with a PAT. | The OAuth connector (which exchanges user login codes) | | **Contribution** | A normalized record of one thing a person did on a tracked repo: a PR, review, issue, comment or commit. Keyed by GitHub login — member or not. | `Interaction` (the gamification record in `activity`, members-only) | -| **Allowlist** | The set of repos that count, stored in `github_repositories` and managed in the admin panel. Ingestion (backfill + webhook) is scoped to it. | All org repos (the org webhook delivers everything; we filter) | +| **Allowlist** | The set of repos that count, stored in `github_repositories` (one row per tenant + repo) and managed in the admin panel. Ingestion is scoped to it and fans out per tenant tracking the repo. | All org repos (the org webhook delivers everything; we filter) | | **Event lake** | `github_event_logs` — the raw, append-only store of webhook payloads (deduped by delivery id), for audit and replay. | `github_contributions` (the normalized read model) | | **Backfill** | Historical import via paginated REST list requests, upserting contributions directly. Resumable via `last_backfilled_at`. | Webhook ingestion (the live stream through the lake) | diff --git a/app-modules/integration-github/database/factories/GithubContributionFactory.php b/app-modules/integration-github/database/factories/GithubContributionFactory.php index 3f7b832d5..bb34e96ec 100644 --- a/app-modules/integration-github/database/factories/GithubContributionFactory.php +++ b/app-modules/integration-github/database/factories/GithubContributionFactory.php @@ -4,6 +4,7 @@ namespace He4rt\IntegrationGithub\Database\Factories; +use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationGithub\Enums\ContributionType; use He4rt\IntegrationGithub\Models\GithubContribution; use Illuminate\Database\Eloquent\Factories\Factory; @@ -23,6 +24,7 @@ public function definition(): array $number = fake()->unique()->numberBetween(1, 1_000_000); return [ + 'tenant_id' => Tenant::factory(), 'repo' => 'he4rt/heartdevs.com', 'actor_login' => fake()->userName(), 'actor_id' => fake()->numberBetween(1, 9_999_999), diff --git a/app-modules/integration-github/database/factories/GithubRepositoryFactory.php b/app-modules/integration-github/database/factories/GithubRepositoryFactory.php index e11163bf0..eb09c0c15 100644 --- a/app-modules/integration-github/database/factories/GithubRepositoryFactory.php +++ b/app-modules/integration-github/database/factories/GithubRepositoryFactory.php @@ -4,6 +4,7 @@ namespace He4rt\IntegrationGithub\Database\Factories; +use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationGithub\Models\GithubRepository; use Illuminate\Database\Eloquent\Factories\Factory; @@ -20,6 +21,7 @@ final class GithubRepositoryFactory extends Factory public function definition(): array { return [ + 'tenant_id' => Tenant::factory(), 'full_name' => 'he4rt/'.fake()->unique()->slug(2), 'enabled' => true, 'last_backfilled_at' => null, diff --git a/app-modules/integration-github/database/migrations/2026_06_04_000001_create_github_repositories_table.php b/app-modules/integration-github/database/migrations/2026_06_04_000001_create_github_repositories_table.php index 54b5d07d1..6c210e231 100644 --- a/app-modules/integration-github/database/migrations/2026_06_04_000001_create_github_repositories_table.php +++ b/app-modules/integration-github/database/migrations/2026_06_04_000001_create_github_repositories_table.php @@ -12,10 +12,15 @@ public function up(): void { Schema::create('github_repositories', function (Blueprint $table): void { $table->uuid('id')->primary(); - $table->string('full_name')->unique(); // owner/repo + $table->foreignUuid('tenant_id')->constrained('tenants'); + $table->string('full_name'); // owner/repo $table->boolean('enabled')->default(true); $table->timestamp('last_backfilled_at')->nullable(); $table->timestamps(); + + // A allowlist é por tenant: cada comunidade mantém a sua, e o mesmo + // repo público pode ser acompanhado por mais de uma comunidade. + $table->unique(['tenant_id', 'full_name'], 'uniq_github_repositories_tenant_repo'); }); } diff --git a/app-modules/integration-github/database/migrations/2026_06_04_000002_create_github_contributions_table.php b/app-modules/integration-github/database/migrations/2026_06_04_000002_create_github_contributions_table.php index faf988d3d..820c56a30 100644 --- a/app-modules/integration-github/database/migrations/2026_06_04_000002_create_github_contributions_table.php +++ b/app-modules/integration-github/database/migrations/2026_06_04_000002_create_github_contributions_table.php @@ -12,6 +12,7 @@ public function up(): void { Schema::create('github_contributions', function (Blueprint $table): void { $table->uuid('id')->primary(); + $table->foreignUuid('tenant_id')->constrained('tenants'); $table->string('repo'); $table->string('actor_login'); $table->unsignedBigInteger('actor_id')->nullable(); @@ -22,10 +23,12 @@ public function up(): void $table->jsonb('metadata')->nullable(); $table->timestamps(); - $table->unique(['repo', 'type', 'external_ref'], 'uniq_github_contributions_ref'); - $table->index(['repo', 'occurred_at'], 'idx_github_contributions_repo_time'); + // Isolamento por tenant: a mesma contribuição de um repo compartilhado + // é gravada uma vez por comunidade que acompanha o repo. + $table->unique(['tenant_id', 'repo', 'type', 'external_ref'], 'uniq_github_contributions_ref'); + $table->index(['tenant_id', 'occurred_at'], 'idx_github_contributions_tenant_time'); $table->index('actor_id', 'idx_github_contributions_actor'); - $table->index(['type', 'occurred_at'], 'idx_github_contributions_type_time'); + $table->index(['tenant_id', 'type', 'occurred_at'], 'idx_github_contributions_type_time'); }); } diff --git a/app-modules/integration-github/docs/adr/0001-github-community-contributions.md b/app-modules/integration-github/docs/adr/0001-github-community-contributions.md index af43ec63d..9be4fc99e 100644 --- a/app-modules/integration-github/docs/adr/0001-github-community-contributions.md +++ b/app-modules/integration-github/docs/adr/0001-github-community-contributions.md @@ -23,7 +23,7 @@ Build the ingestion in `integration-github` (transport), mirroring the Discord d ### 1. Own contribution model, not `Interaction` -A normalized `github_contributions` table keyed by GitHub login/id (member-or-not), with `unique(repo, type, external_ref)` for idempotency. Gamification is left as a **seam**: this module emits `GithubContributionRecorded` and does not depend on `activity`/`economy`. +A normalized `github_contributions` table keyed by GitHub login/id (member-or-not), with `unique(tenant_id, repo, type, external_ref)` for idempotency. Gamification is left as a **seam**: this module emits `GithubContributionRecorded` and does not depend on `activity`/`economy`. ### 2. Normalized read model + raw lake @@ -31,12 +31,16 @@ A normalized `github_contributions` table keyed by GitHub login/id (member-or-no ### 3. Split ingestion paths -- **Webhook (live):** org webhook → signature-verified → raw payload into the lake → `ProjectGithubEvent` projects into contributions. -- **Backfill (history):** paginated REST list requests upsert contributions directly. Resumable via `last_backfilled_at`. The `unique(repo, type, external_ref)` key makes both paths converge without duplicates. +- **Webhook (live):** org webhook → signature-verified → raw payload into the lake → `ProjectGithubEvent` **fans out**, projecting one contribution per tenant whose allowlist enables the repo. +- **Backfill (history):** paginated REST list requests upsert contributions directly, stamping the source repo's `tenant_id`. Resumable via `last_backfilled_at`. The `unique(tenant_id, repo, type, external_ref)` key makes both paths converge without duplicates. -### 4. Panel-managed allowlist +### 4. Panel-managed allowlist, scoped per tenant -`github_repositories` (managed in `panel-admin`) is the source of truth for which repos count. The org webhook delivers everything; ingestion filters by the allowlist. +`github_repositories` (managed in `panel-admin`) is the source of truth for which repos count. **Each repo belongs to a tenant** (`unique(tenant_id, full_name)`): the allowlist is per community, and the same public repo may be tracked by more than one tenant. The org webhook delivers everything; ingestion filters by the allowlist and fans out to every tenant tracking the repo. + +### 4b. Tenant isolation + +Both `github_repositories` and `github_contributions` carry `tenant_id`; the presentation, the panel resource and the live/backfill writers are all scoped by it. The raw lake (`github_event_logs`) stays **global** — one delivery is stored once and projected to N tenants. Because public GitHub data is identical across communities, a shared repo's contributions are deliberately **duplicated per tenant** (one row each) to keep every community's read model fully isolated and independently queryable. The public retrospective resolves the tenant from a route slug (`/comunidade/{tenant}/retrospectiva`), defaulting to `config('he4rt.main_tenant')` when none is given. ### 5. Store everything, filter on read @@ -48,7 +52,7 @@ A fine-grained PAT (`services.github.api_token`) authenticates the REST connecto ## Consequences -- **Positive:** the presentation is fast and queryable by period/person/type; history and live converge idempotently; gamification can be wired later without re-modeling; adding a repo is a panel action. -- **Negative:** PR size enrichment costs one extra request per PR (the dominant cost — ~2 requests/PR); reactions are out of scope; the org webhook delivers traffic for non-allowlisted repos (stored in the lake for audit, then ignored). +- **Positive:** the presentation is fast and queryable by period/person/type; history and live converge idempotently; gamification can be wired later without re-modeling; adding a repo is a panel action; each community's data is fully isolated and consistent with the rest of the multi-tenant panel. +- **Negative:** PR size enrichment costs one extra request per PR (the dominant cost — ~2 requests/PR); reactions are out of scope; the org webhook delivers traffic for non-allowlisted repos (stored in the lake for audit, then ignored); a repo shared by N tenants stores its contributions N times (accepted: public data is small and isolation is worth more than the dedup). - **Backfill scale & resilience:** the backfill is **synchronous and rate-limit-aware** — every request uses Saloon `->throw()`, so a 403/5xx fails loudly (no silent corruption); on a rate-limit 403 both backfill transports (the `github:backfill` console command and the panel's "Backfill agora" action) surface a clear, actionable message and is safely resumable (idempotent upsert means a re-run after the reset costs nothing extra and never duplicates). The rate-limit interpretation lives in a single shared `Backfill\RateLimit` helper so the two transports cannot drift. At current scale this is enough: the main repo (~230 PRs) is ~500 requests, ~10% of the 5,000/hour budget. A **queue** (job-per-repo with release-on-limit) and a true **incremental** refresh (using `last_backfilled_at` as `since` — today it is recorded but each run still does full history) are deliberately deferred as YAGNI; revisit if many large repos are added. - **Follow-up:** revisit a lake retention/TTL policy; decide whether commits without a linked GitHub user enter the per-person ranking; implement the gamification bridge via the seam event. diff --git a/app-modules/integration-github/src/Backfill/BackfillRepository.php b/app-modules/integration-github/src/Backfill/BackfillRepository.php index b6f19ca7b..929ae663c 100644 --- a/app-modules/integration-github/src/Backfill/BackfillRepository.php +++ b/app-modules/integration-github/src/Backfill/BackfillRepository.php @@ -6,6 +6,7 @@ use He4rt\IntegrationGithub\Contributions\RecordContribution; use He4rt\IntegrationGithub\Enums\ContributionType; +use He4rt\IntegrationGithub\Models\GithubRepository; use He4rt\IntegrationGithub\Transport\GitHubApiConnector; use He4rt\IntegrationGithub\Transport\Requests\Contributions\GetPullRequest; use He4rt\IntegrationGithub\Transport\Requests\Contributions\ListCommits; @@ -25,26 +26,29 @@ public function __construct( private RecordContribution $recorder, ) {} - public function execute(string $repo): void + public function execute(GithubRepository $repository): void { - $this->backfillPullRequests($repo); - $this->backfillIssues($repo); - $this->backfillIssueComments($repo); - $this->backfillReviewComments($repo); - $this->backfillCommits($repo); + $tenantId = $repository->tenant_id; + $repo = $repository->full_name; + + $this->backfillPullRequests($tenantId, $repo); + $this->backfillIssues($tenantId, $repo); + $this->backfillIssueComments($tenantId, $repo); + $this->backfillReviewComments($tenantId, $repo); + $this->backfillCommits($tenantId, $repo); } - private function backfillPullRequests(string $repo): void + private function backfillPullRequests(string $tenantId, string $repo): void { $this->paginate( fn (int $page): Request => new ListPullRequests($repo, $page, self::PER_PAGE), - function (array $pr) use ($repo): void { + function (array $pr) use ($tenantId, $repo): void { $number = (int) ($pr['number'] ?? 0); /** @var array $detail */ $detail = (array) $this->github->send(new GetPullRequest($repo, $number))->throw()->json(); $login = $this->login($pr); - $this->upsert($repo, ContributionType::Pr, 'pr:'.$number, $login, $this->userId($pr), $this->strv($pr, 'created_at'), null, [ + $this->upsert($tenantId, $repo, ContributionType::Pr, 'pr:'.$number, $login, $this->userId($pr), $this->strv($pr, 'created_at'), null, [ 'title' => $pr['title'] ?? null, 'state' => $pr['state'] ?? null, 'merged' => ($pr['merged_at'] ?? null) !== null, @@ -55,19 +59,19 @@ function (array $pr) use ($repo): void { 'is_bot' => $this->isBot($login), ]); - $this->backfillReviews($repo, $number); + $this->backfillReviews($tenantId, $repo, $number); }, ); } - private function backfillReviews(string $repo, int $number): void + private function backfillReviews(string $tenantId, string $repo, int $number): void { $this->paginate( fn (int $page): Request => new ListPullRequestReviews($repo, $number, $page, self::PER_PAGE), - function (array $review) use ($repo, $number): void { + function (array $review) use ($tenantId, $repo, $number): void { $login = $this->login($review); - $this->upsert($repo, ContributionType::Review, 'review:'.($review['id'] ?? ''), $login, $this->userId($review), $this->strv($review, 'submitted_at'), 'pr:'.$number, [ + $this->upsert($tenantId, $repo, ContributionType::Review, 'review:'.($review['id'] ?? ''), $login, $this->userId($review), $this->strv($review, 'submitted_at'), 'pr:'.$number, [ 'state' => $review['state'] ?? null, 'is_bot' => $this->isBot($login), ]); @@ -75,18 +79,18 @@ function (array $review) use ($repo, $number): void { ); } - private function backfillIssues(string $repo): void + private function backfillIssues(string $tenantId, string $repo): void { $this->paginate( fn (int $page): Request => new ListIssues($repo, $page, self::PER_PAGE), - function (array $issue) use ($repo): void { + function (array $issue) use ($tenantId, $repo): void { if (isset($issue['pull_request'])) { return; // o endpoint de issues também devolve PRs; estes já entram via backfillPullRequests } $login = $this->login($issue); - $this->upsert($repo, ContributionType::Issue, 'issue:'.($issue['number'] ?? ''), $login, $this->userId($issue), $this->strv($issue, 'created_at'), null, [ + $this->upsert($tenantId, $repo, ContributionType::Issue, 'issue:'.($issue['number'] ?? ''), $login, $this->userId($issue), $this->strv($issue, 'created_at'), null, [ 'title' => $issue['title'] ?? null, 'state' => $issue['state'] ?? null, 'url' => $issue['html_url'] ?? null, @@ -96,14 +100,14 @@ function (array $issue) use ($repo): void { ); } - private function backfillIssueComments(string $repo): void + private function backfillIssueComments(string $tenantId, string $repo): void { $this->paginate( fn (int $page): Request => new ListIssueComments($repo, $page, self::PER_PAGE), - function (array $comment) use ($repo): void { + function (array $comment) use ($tenantId, $repo): void { $login = $this->login($comment); - $this->upsert($repo, ContributionType::Comment, 'comment:'.($comment['id'] ?? ''), $login, $this->userId($comment), $this->strv($comment, 'created_at'), $this->refFromUrl($this->strv($comment, 'issue_url'), 'issue'), [ + $this->upsert($tenantId, $repo, ContributionType::Comment, 'comment:'.($comment['id'] ?? ''), $login, $this->userId($comment), $this->strv($comment, 'created_at'), $this->refFromUrl($this->strv($comment, 'issue_url'), 'issue'), [ 'url' => $comment['html_url'] ?? null, 'kind' => 'issue', 'is_bot' => $this->isBot($login), @@ -112,14 +116,14 @@ function (array $comment) use ($repo): void { ); } - private function backfillReviewComments(string $repo): void + private function backfillReviewComments(string $tenantId, string $repo): void { $this->paginate( fn (int $page): Request => new ListPullRequestReviewComments($repo, $page, self::PER_PAGE), - function (array $comment) use ($repo): void { + function (array $comment) use ($tenantId, $repo): void { $login = $this->login($comment); - $this->upsert($repo, ContributionType::Comment, 'review_comment:'.($comment['id'] ?? ''), $login, $this->userId($comment), $this->strv($comment, 'created_at'), $this->refFromUrl($this->strv($comment, 'pull_request_url'), 'pr'), [ + $this->upsert($tenantId, $repo, ContributionType::Comment, 'review_comment:'.($comment['id'] ?? ''), $login, $this->userId($comment), $this->strv($comment, 'created_at'), $this->refFromUrl($this->strv($comment, 'pull_request_url'), 'pr'), [ 'url' => $comment['html_url'] ?? null, 'kind' => 'pr', 'is_bot' => $this->isBot($login), @@ -128,11 +132,11 @@ function (array $comment) use ($repo): void { ); } - private function backfillCommits(string $repo): void + private function backfillCommits(string $tenantId, string $repo): void { $this->paginate( fn (int $page): Request => new ListCommits($repo, $page, self::PER_PAGE), - function (array $commit) use ($repo): void { + function (array $commit) use ($tenantId, $repo): void { $author = is_array($commit['author'] ?? null) ? $commit['author'] : []; $commitMeta = is_array($commit['commit'] ?? null) ? $commit['commit'] : []; $commitAuthor = is_array($commitMeta['author'] ?? null) ? $commitMeta['author'] : []; @@ -144,7 +148,7 @@ function (array $commit) use ($repo): void { $actorId = isset($author['id']) && is_numeric($author['id']) ? (int) $author['id'] : null; $date = isset($commitAuthor['date']) && is_string($commitAuthor['date']) ? $commitAuthor['date'] : ''; - $this->upsert($repo, ContributionType::Commit, 'commit:'.($commit['sha'] ?? ''), $login, $actorId, $date, null, [ + $this->upsert($tenantId, $repo, ContributionType::Commit, 'commit:'.($commit['sha'] ?? ''), $login, $actorId, $date, null, [ 'url' => $commit['html_url'] ?? null, 'is_bot' => $this->isBot($login), ]); @@ -176,6 +180,7 @@ private function paginate(callable $request, callable $handle): void * @param array $metadata */ private function upsert( + string $tenantId, string $repo, ContributionType $type, string $externalRef, @@ -185,7 +190,7 @@ private function upsert( ?string $targetRef, array $metadata, ): void { - $this->recorder->execute($repo, $type, $externalRef, $actorLogin, $actorId, $occurredAt, $targetRef, $metadata); + $this->recorder->execute($tenantId, $repo, $type, $externalRef, $actorLogin, $actorId, $occurredAt, $targetRef, $metadata); } /** diff --git a/app-modules/integration-github/src/Console/BackfillGithubCommand.php b/app-modules/integration-github/src/Console/BackfillGithubCommand.php index 31ad37414..c8771a770 100644 --- a/app-modules/integration-github/src/Console/BackfillGithubCommand.php +++ b/app-modules/integration-github/src/Console/BackfillGithubCommand.php @@ -37,7 +37,7 @@ public function handle(BackfillRepository $backfill): int $this->info(sprintf('Backfilling %s...', $repository->full_name)); try { - $backfill->execute($repository->full_name); + $backfill->execute($repository); } catch (RequestException $exception) { if (RateLimit::matches($exception)) { $this->warn(sprintf( diff --git a/app-modules/integration-github/src/Contributions/RecordContribution.php b/app-modules/integration-github/src/Contributions/RecordContribution.php index b940e3d31..27e594853 100644 --- a/app-modules/integration-github/src/Contributions/RecordContribution.php +++ b/app-modules/integration-github/src/Contributions/RecordContribution.php @@ -11,7 +11,7 @@ /** * Idempotent writer for contributions, shared by backfill (bulk, silent) and * webhook ingestion (live, emits the seam event). Convergence is guaranteed by - * the unique (repo, type, external_ref) key. + * the unique (tenant_id, repo, type, external_ref) key. */ final class RecordContribution { @@ -19,6 +19,7 @@ final class RecordContribution * @param array $metadata */ public function execute( + string $tenantId, string $repo, ContributionType $type, string $externalRef, @@ -30,7 +31,7 @@ public function execute( bool $emit = false, ): GithubContribution { $contribution = GithubContribution::query()->updateOrCreate( - ['repo' => $repo, 'type' => $type, 'external_ref' => $externalRef], + ['tenant_id' => $tenantId, 'repo' => $repo, 'type' => $type, 'external_ref' => $externalRef], [ 'actor_login' => $actorLogin, 'actor_id' => $actorId, diff --git a/app-modules/integration-github/src/Models/GithubContribution.php b/app-modules/integration-github/src/Models/GithubContribution.php index ae23999d2..e030e77e7 100644 --- a/app-modules/integration-github/src/Models/GithubContribution.php +++ b/app-modules/integration-github/src/Models/GithubContribution.php @@ -5,15 +5,18 @@ namespace He4rt\IntegrationGithub\Models; use Carbon\Carbon; +use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationGithub\Database\Factories\GithubContributionFactory; use He4rt\IntegrationGithub\Enums\ContributionType; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * @property string $id + * @property string $tenant_id * @property string $repo * @property string $actor_login * @property int|null $actor_id @@ -32,6 +35,14 @@ final class GithubContribution extends Model use HasFactory; use HasUuids; + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + protected static function newFactory(): GithubContributionFactory { return GithubContributionFactory::new(); diff --git a/app-modules/integration-github/src/Models/GithubRepository.php b/app-modules/integration-github/src/Models/GithubRepository.php index da9321d91..69dbee0a9 100644 --- a/app-modules/integration-github/src/Models/GithubRepository.php +++ b/app-modules/integration-github/src/Models/GithubRepository.php @@ -5,15 +5,18 @@ namespace He4rt\IntegrationGithub\Models; use Carbon\Carbon; +use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationGithub\Database\Factories\GithubRepositoryFactory; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * @property string $id + * @property string $tenant_id * @property string $full_name * @property bool $enabled * @property Carbon|null $last_backfilled_at @@ -27,6 +30,14 @@ final class GithubRepository extends Model use HasFactory; use HasUuids; + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + protected static function newFactory(): GithubRepositoryFactory { return GithubRepositoryFactory::new(); diff --git a/app-modules/integration-github/src/Webhook/ProjectGithubEvent.php b/app-modules/integration-github/src/Webhook/ProjectGithubEvent.php index 093825b6b..e05986d3b 100644 --- a/app-modules/integration-github/src/Webhook/ProjectGithubEvent.php +++ b/app-modules/integration-github/src/Webhook/ProjectGithubEvent.php @@ -25,35 +25,46 @@ public function execute(string $event, array $payload): void { $repo = $this->str($payload, 'repository.full_name'); - if ($repo === '' || !$this->allowlisted($repo)) { + if ($repo === '') { return; } - match ($event) { - 'pull_request' => $this->pullRequest($repo, $payload), - 'pull_request_review' => $this->review($repo, $payload), - 'issues' => $this->issue($repo, $payload), - 'issue_comment' => $this->issueComment($repo, $payload), - 'pull_request_review_comment' => $this->reviewComment($repo, $payload), - 'push' => $this->push($repo, $payload), - default => null, - }; + // Fan-out: cada comunidade que acompanha esse repo recebe a sua própria + // contribuição (isolamento por tenant). Uma entrega vira N projeções. + foreach ($this->tenantsTracking($repo) as $tenantId) { + match ($event) { + 'pull_request' => $this->pullRequest($tenantId, $repo, $payload), + 'pull_request_review' => $this->review($tenantId, $repo, $payload), + 'issues' => $this->issue($tenantId, $repo, $payload), + 'issue_comment' => $this->issueComment($tenantId, $repo, $payload), + 'pull_request_review_comment' => $this->reviewComment($tenantId, $repo, $payload), + 'push' => $this->push($tenantId, $repo, $payload), + default => null, + }; + } } - private function allowlisted(string $repo): bool + /** + * @return list + */ + private function tenantsTracking(string $repo): array { - return GithubRepository::query()->enabled()->where('full_name', $repo)->exists(); + return GithubRepository::query() + ->enabled() + ->where('full_name', $repo) + ->pluck('tenant_id') + ->all(); } /** * @param array $payload */ - private function pullRequest(string $repo, array $payload): void + private function pullRequest(string $tenantId, string $repo, array $payload): void { $pr = $this->arr($payload, 'pull_request'); $login = $this->str($pr, 'user.login', 'ghost'); - $this->recorder->execute($repo, ContributionType::Pr, 'pr:'.$this->str($pr, 'number'), $login, $this->intOrNull($pr, 'user.id'), $this->str($pr, 'created_at'), null, [ + $this->recorder->execute($tenantId, $repo, ContributionType::Pr, 'pr:'.$this->str($pr, 'number'), $login, $this->intOrNull($pr, 'user.id'), $this->str($pr, 'created_at'), null, [ 'title' => data_get($pr, 'title'), 'state' => data_get($pr, 'state'), 'merged' => data_get($pr, 'merged_at') !== null, @@ -68,12 +79,12 @@ private function pullRequest(string $repo, array $payload): void /** * @param array $payload */ - private function review(string $repo, array $payload): void + private function review(string $tenantId, string $repo, array $payload): void { $review = $this->arr($payload, 'review'); $login = $this->str($review, 'user.login', 'ghost'); - $this->recorder->execute($repo, ContributionType::Review, 'review:'.$this->str($review, 'id'), $login, $this->intOrNull($review, 'user.id'), $this->str($review, 'submitted_at'), 'pr:'.$this->str($payload, 'pull_request.number'), [ + $this->recorder->execute($tenantId, $repo, ContributionType::Review, 'review:'.$this->str($review, 'id'), $login, $this->intOrNull($review, 'user.id'), $this->str($review, 'submitted_at'), 'pr:'.$this->str($payload, 'pull_request.number'), [ 'state' => data_get($review, 'state'), 'is_bot' => str_ends_with($login, '[bot]'), ], emit: true); @@ -82,12 +93,12 @@ private function review(string $repo, array $payload): void /** * @param array $payload */ - private function issue(string $repo, array $payload): void + private function issue(string $tenantId, string $repo, array $payload): void { $issue = $this->arr($payload, 'issue'); $login = $this->str($issue, 'user.login', 'ghost'); - $this->recorder->execute($repo, ContributionType::Issue, 'issue:'.$this->str($issue, 'number'), $login, $this->intOrNull($issue, 'user.id'), $this->str($issue, 'created_at'), null, [ + $this->recorder->execute($tenantId, $repo, ContributionType::Issue, 'issue:'.$this->str($issue, 'number'), $login, $this->intOrNull($issue, 'user.id'), $this->str($issue, 'created_at'), null, [ 'title' => data_get($issue, 'title'), 'state' => data_get($issue, 'state'), 'url' => data_get($issue, 'html_url'), @@ -98,14 +109,14 @@ private function issue(string $repo, array $payload): void /** * @param array $payload */ - private function issueComment(string $repo, array $payload): void + private function issueComment(string $tenantId, string $repo, array $payload): void { $comment = $this->arr($payload, 'comment'); $login = $this->str($comment, 'user.login', 'ghost'); $isPr = data_get($payload, 'issue.pull_request') !== null; $target = ($isPr ? 'pr:' : 'issue:').$this->str($payload, 'issue.number'); - $this->recorder->execute($repo, ContributionType::Comment, 'comment:'.$this->str($comment, 'id'), $login, $this->intOrNull($comment, 'user.id'), $this->str($comment, 'created_at'), $target, [ + $this->recorder->execute($tenantId, $repo, ContributionType::Comment, 'comment:'.$this->str($comment, 'id'), $login, $this->intOrNull($comment, 'user.id'), $this->str($comment, 'created_at'), $target, [ 'url' => data_get($comment, 'html_url'), 'kind' => $isPr ? 'pr' : 'issue', 'is_bot' => str_ends_with($login, '[bot]'), @@ -115,12 +126,12 @@ private function issueComment(string $repo, array $payload): void /** * @param array $payload */ - private function reviewComment(string $repo, array $payload): void + private function reviewComment(string $tenantId, string $repo, array $payload): void { $comment = $this->arr($payload, 'comment'); $login = $this->str($comment, 'user.login', 'ghost'); - $this->recorder->execute($repo, ContributionType::Comment, 'review_comment:'.$this->str($comment, 'id'), $login, $this->intOrNull($comment, 'user.id'), $this->str($comment, 'created_at'), 'pr:'.$this->str($payload, 'pull_request.number'), [ + $this->recorder->execute($tenantId, $repo, ContributionType::Comment, 'review_comment:'.$this->str($comment, 'id'), $login, $this->intOrNull($comment, 'user.id'), $this->str($comment, 'created_at'), 'pr:'.$this->str($payload, 'pull_request.number'), [ 'url' => data_get($comment, 'html_url'), 'kind' => 'pr', 'is_bot' => str_ends_with($login, '[bot]'), @@ -130,7 +141,7 @@ private function reviewComment(string $repo, array $payload): void /** * @param array $payload */ - private function push(string $repo, array $payload): void + private function push(string $tenantId, string $repo, array $payload): void { foreach ($this->arr($payload, 'commits') as $commit) { if (!is_array($commit)) { @@ -140,7 +151,7 @@ private function push(string $repo, array $payload): void $username = $this->str($commit, 'author.username'); $login = $username !== '' ? $username : $this->str($commit, 'author.name', 'ghost'); - $this->recorder->execute($repo, ContributionType::Commit, 'commit:'.$this->str($commit, 'id'), $login, null, $this->str($commit, 'timestamp'), null, [ + $this->recorder->execute($tenantId, $repo, ContributionType::Commit, 'commit:'.$this->str($commit, 'id'), $login, null, $this->str($commit, 'timestamp'), null, [ 'url' => data_get($commit, 'url'), 'is_bot' => str_ends_with($login, '[bot]'), ], emit: true); diff --git a/app-modules/integration-github/tests/Feature/BackfillRepositoryTest.php b/app-modules/integration-github/tests/Feature/BackfillRepositoryTest.php index 89d447406..114bf51e9 100644 --- a/app-modules/integration-github/tests/Feature/BackfillRepositoryTest.php +++ b/app-modules/integration-github/tests/Feature/BackfillRepositoryTest.php @@ -5,6 +5,7 @@ use He4rt\IntegrationGithub\Backfill\BackfillRepository; use He4rt\IntegrationGithub\Enums\ContributionType; use He4rt\IntegrationGithub\Models\GithubContribution; +use He4rt\IntegrationGithub\Models\GithubRepository; use He4rt\IntegrationGithub\Transport\GitHubApiConnector; use He4rt\IntegrationGithub\Transport\Requests\Contributions\GetPullRequest; use He4rt\IntegrationGithub\Transport\Requests\Contributions\ListCommits; @@ -17,6 +18,10 @@ use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; +beforeEach(function (): void { + $this->repo = GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']); +}); + /** * Mocks every GitHub endpoint the backfill touches with an empty page by default, * so each test only declares the endpoint it cares about. @@ -57,22 +62,23 @@ function prPayload(int $number, string $login, int $id, ?string $merged = null): ]; } -function backfill(): void +function backfill(GithubRepository $repo): void { - resolve(BackfillRepository::class)->execute('he4rt/heartdevs.com'); + resolve(BackfillRepository::class)->execute($repo); } -it('faz backfill de PRs upsertando contributions com tamanho e autor', function (): void { +it('faz backfill de PRs upsertando contributions com tamanho, autor e tenant', function (): void { mockGithub([ ListPullRequests::class => MockResponse::make([prPayload(1, 'maria', 42)]), GetPullRequest::class => MockResponse::make(['additions' => 10, 'deletions' => 2, 'changed_files' => 3]), ]); - backfill(); + backfill($this->repo); $contribution = GithubContribution::query()->where('external_ref', 'pr:1')->sole(); expect($contribution->type)->toBe(ContributionType::Pr) + ->and($contribution->tenant_id)->toBe($this->repo->tenant_id) ->and($contribution->actor_login)->toBe('maria') ->and($contribution->actor_id)->toBe(42) ->and($contribution->repo)->toBe('he4rt/heartdevs.com') @@ -88,8 +94,8 @@ function backfill(): void GetPullRequest::class => MockResponse::make(['additions' => 10, 'deletions' => 2, 'changed_files' => 3]), ]); - backfill(); - backfill(); + backfill($this->repo); + backfill($this->repo); expect(GithubContribution::query()->where('external_ref', 'pr:1')->count())->toBe(1); }); @@ -100,7 +106,7 @@ function backfill(): void GetPullRequest::class => MockResponse::make(['additions' => 1, 'deletions' => 0, 'changed_files' => 1]), ]); - backfill(); + backfill($this->repo); expect(GithubContribution::query()->where('external_ref', 'pr:7')->sole()->metadata['is_bot'])->toBeTrue(); }); @@ -114,7 +120,7 @@ function backfill(): void ]), ]); - backfill(); + backfill($this->repo); $review = GithubContribution::query()->where('type', ContributionType::Review)->sole(); @@ -132,7 +138,7 @@ function backfill(): void ]), ]); - backfill(); + backfill($this->repo); expect(GithubContribution::query()->where('type', ContributionType::Issue)->pluck('external_ref')->all()) ->toBe(['issue:10']); @@ -145,7 +151,7 @@ function backfill(): void ]), ]); - backfill(); + backfill($this->repo); $comment = GithubContribution::query()->where('type', ContributionType::Comment)->sole(); @@ -161,7 +167,7 @@ function backfill(): void ]), ]); - backfill(); + backfill($this->repo); $comment = GithubContribution::query()->where('external_ref', 'review_comment:1200')->sole(); @@ -174,7 +180,7 @@ function backfill(): void ListPullRequests::class => MockResponse::make(['message' => 'Internal Server Error'], 500), ]); - expect(fn (): mixed => resolve(BackfillRepository::class)->execute('he4rt/heartdevs.com')) + expect(fn (): mixed => resolve(BackfillRepository::class)->execute($this->repo)) ->toThrow(RequestException::class); }); @@ -186,7 +192,7 @@ function backfill(): void ]), ]); - backfill(); + backfill($this->repo); $linked = GithubContribution::query()->where('external_ref', 'commit:abc123')->sole(); $unlinked = GithubContribution::query()->where('external_ref', 'commit:def456')->sole(); diff --git a/app-modules/integration-github/tests/Feature/GithubContributionTest.php b/app-modules/integration-github/tests/Feature/GithubContributionTest.php index 918e4ab69..83a97dadf 100644 --- a/app-modules/integration-github/tests/Feature/GithubContributionTest.php +++ b/app-modules/integration-github/tests/Feature/GithubContributionTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationGithub\Enums\ContributionType; use He4rt\IntegrationGithub\Models\GithubContribution; use Illuminate\Database\QueryException; @@ -19,27 +20,43 @@ expect($contribution->type)->toBe(ContributionType::Pr) ->and($contribution->occurred_at)->toBeInstanceOf(Carbon::class) ->and($contribution->metadata['additions'])->toBe(10) - ->and($contribution->id)->toBeString(); + ->and($contribution->id)->toBeString() + ->and($contribution->tenant)->toBeInstanceOf(Tenant::class); }); -it('impede contribuição duplicada por (repo, type, external_ref)', function (): void { - GithubContribution::factory()->create([ +it('impede contribuição duplicada por (tenant, repo, type, external_ref)', function (): void { + $tenant = Tenant::factory()->create(); + + GithubContribution::factory()->for($tenant)->create([ 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', ]); - GithubContribution::factory()->create([ + GithubContribution::factory()->for($tenant)->create([ 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', ]); })->throws(QueryException::class); -it('permite o mesmo external_ref em repos ou tipos diferentes', function (): void { +it('permite a mesma contribuição em tenants diferentes', function (): void { GithubContribution::factory()->create([ 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', ]); GithubContribution::factory()->create([ + 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', + ]); + + expect(GithubContribution::query()->where('external_ref', 'pr:1')->count())->toBe(2); +}); + +it('permite o mesmo external_ref em repos ou tipos diferentes', function (): void { + $tenant = Tenant::factory()->create(); + + GithubContribution::factory()->for($tenant)->create([ + 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', + ]); + GithubContribution::factory()->for($tenant)->create([ 'repo' => 'he4rt/4noobs', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', ]); - GithubContribution::factory()->create([ + GithubContribution::factory()->for($tenant)->create([ 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Issue, 'external_ref' => 'pr:1', ]); diff --git a/app-modules/integration-github/tests/Feature/GithubRepositoryTest.php b/app-modules/integration-github/tests/Feature/GithubRepositoryTest.php index 83037dce1..1be138c0d 100644 --- a/app-modules/integration-github/tests/Feature/GithubRepositoryTest.php +++ b/app-modules/integration-github/tests/Feature/GithubRepositoryTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationGithub\Models\GithubRepository; use Illuminate\Database\QueryException; @@ -11,15 +12,24 @@ expect($repo->full_name)->toBe('he4rt/heartdevs.com') ->and($repo->enabled)->toBeTrue() ->and($repo->last_backfilled_at)->toBeNull() - ->and($repo->id)->toBeString(); + ->and($repo->id)->toBeString() + ->and($repo->tenant)->toBeInstanceOf(Tenant::class); }); -it('impede full_name duplicado pela unique', function (): void { - GithubRepository::factory()->create(['full_name' => 'he4rt/4noobs']); +it('impede full_name duplicado no mesmo tenant', function (): void { + $tenant = Tenant::factory()->create(); - GithubRepository::factory()->create(['full_name' => 'he4rt/4noobs']); + GithubRepository::factory()->for($tenant)->create(['full_name' => 'he4rt/4noobs']); + GithubRepository::factory()->for($tenant)->create(['full_name' => 'he4rt/4noobs']); })->throws(QueryException::class); +it('permite o mesmo full_name em tenants diferentes', function (): void { + GithubRepository::factory()->create(['full_name' => 'he4rt/4noobs']); + GithubRepository::factory()->create(['full_name' => 'he4rt/4noobs']); + + expect(GithubRepository::query()->where('full_name', 'he4rt/4noobs')->count())->toBe(2); +}); + it('o scope enabled retorna somente os repositórios habilitados', function (): void { GithubRepository::factory()->count(2)->create(); GithubRepository::factory()->disabled()->create(); diff --git a/app-modules/integration-github/tests/Feature/GithubWebhookTest.php b/app-modules/integration-github/tests/Feature/GithubWebhookTest.php index 86baff7ca..b35c345cf 100644 --- a/app-modules/integration-github/tests/Feature/GithubWebhookTest.php +++ b/app-modules/integration-github/tests/Feature/GithubWebhookTest.php @@ -60,7 +60,7 @@ function prWebhookPayload(string $repo = 'he4rt/heartdevs.com', int $number = 1, it('grava no lake e projeta a contribuição para repo na allowlist, emitindo o evento', function (): void { Event::fake([GithubContributionRecorded::class]); - GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']); + $repo = GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']); postGithubWebhook('pull_request', prWebhookPayload())->assertSuccessful(); @@ -69,12 +69,25 @@ function prWebhookPayload(string $repo = 'he4rt/heartdevs.com', int $number = 1, $contribution = GithubContribution::query()->where('external_ref', 'pr:1')->sole(); expect($contribution->type)->toBe(ContributionType::Pr) + ->and($contribution->tenant_id)->toBe($repo->tenant_id) ->and($contribution->actor_login)->toBe('maria') ->and($contribution->metadata['additions'])->toBe(5); Event::assertDispatched(GithubContributionRecorded::class); }); +it('faz fan-out: projeta uma contribuição por tenant que acompanha o repo', function (): void { + $a = GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']); + $b = GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']); + GithubRepository::factory()->disabled()->create(['full_name' => 'he4rt/heartdevs.com']); + + postGithubWebhook('pull_request', prWebhookPayload())->assertSuccessful(); + + expect(GithubEventLog::query()->count())->toBe(1) + ->and(GithubContribution::query()->where('external_ref', 'pr:1')->pluck('tenant_id')->sort()->values()->all()) + ->toBe(collect([$a->tenant_id, $b->tenant_id])->sort()->values()->all()); +}); + it('deduplica entregas repetidas pelo delivery id', function (): void { GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']); $delivery = Str::uuid()->toString(); diff --git a/app-modules/panel-admin/src/Github/Resources/GithubRepositoryResource.php b/app-modules/panel-admin/src/Github/Resources/GithubRepositoryResource.php index a2255d692..9c696f952 100644 --- a/app-modules/panel-admin/src/Github/Resources/GithubRepositoryResource.php +++ b/app-modules/panel-admin/src/Github/Resources/GithubRepositoryResource.php @@ -7,6 +7,7 @@ use BackedEnum; use Filament\Actions\Action; use Filament\Actions\EditAction; +use Filament\Facades\Filament; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Notifications\Notification; @@ -24,6 +25,7 @@ use He4rt\PanelAdmin\Github\Resources\GithubRepositoryResource\Pages\EditGithubRepository; use He4rt\PanelAdmin\Github\Resources\GithubRepositoryResource\Pages\ListGithubRepositories; use Illuminate\Support\Facades\Date; +use Illuminate\Validation\Rules\Unique; use Saloon\Exceptions\Request\RequestException; class GithubRepositoryResource extends Resource @@ -62,7 +64,9 @@ public static function form(Schema $schema): Schema ->required() ->maxLength(255) ->rule('regex:/^[\w.-]+\/[\w.-]+$/') - ->unique(ignoreRecord: true), + // Unicidade é por tenant: o mesmo repo pode ser acompanhado por + // mais de uma comunidade, então a validação respeita o tenant atual. + ->unique(ignoreRecord: true, modifyRuleUsing: fn (Unique $rule): Unique => $rule->where('tenant_id', Filament::getTenant()?->getKey())), Toggle::make('enabled') ->label('Habilitado') ->default(true), @@ -104,7 +108,7 @@ public static function backfillAction(): Action ->requiresConfirmation() ->action(function (GithubRepository $record): void { try { - resolve(BackfillRepository::class)->execute($record->full_name); + resolve(BackfillRepository::class)->execute($record); } catch (RequestException $requestException) { if (RateLimit::matches($requestException)) { Notification::make() diff --git a/app-modules/panel-admin/tests/Feature/Github/GithubRepositoryResourceTest.php b/app-modules/panel-admin/tests/Feature/Github/GithubRepositoryResourceTest.php index bbe4283e8..5f224534b 100644 --- a/app-modules/panel-admin/tests/Feature/Github/GithubRepositoryResourceTest.php +++ b/app-modules/panel-admin/tests/Feature/Github/GithubRepositoryResourceTest.php @@ -15,14 +15,6 @@ use function Pest\Livewire\livewire; -function mockGithubConnector(MockResponse $response): void -{ - app()->instance(GitHubApiConnector::class, tap( - new GitHubApiConnector(), - fn (GitHubApiConnector $connector) => $connector->withMockClient(new MockClient(['*' => $response])), - )); -} - beforeEach(function (): void { $user = User::factory()->create(['username' => 'danielhe4rt']); $tenant = Tenant::factory()->create(['slug' => 'he4rt-dev']); @@ -34,15 +26,30 @@ function mockGithubConnector(MockResponse $response): void Filament::setCurrentPanel(Filament::getPanel('admin')); Filament::setTenant($tenant); + // Boota o painel para registrar o global scope + o observer de tenancy + // (em testes Livewire o painel não passa pelo middleware que faz isso no HTTP). + Filament::getPanel('admin')->boot(); + + $this->tenant = $tenant; }); -test('admin cria um repositório da allowlist', function (): void { +function mockGithubConnector(MockResponse $response): void +{ + app()->instance(GitHubApiConnector::class, tap( + new GitHubApiConnector(), + fn (GitHubApiConnector $connector) => $connector->withMockClient(new MockClient(['*' => $response])), + )); +} + +test('admin cria um repositório associando ao tenant atual', function (): void { livewire(CreateGithubRepository::class) ->fillForm(['full_name' => 'he4rt/heartdevs.com', 'enabled' => true]) ->call('create') ->assertHasNoFormErrors(); - expect(GithubRepository::query()->where('full_name', 'he4rt/heartdevs.com')->exists())->toBeTrue(); + $repo = GithubRepository::query()->where('full_name', 'he4rt/heartdevs.com')->sole(); + + expect($repo->tenant_id)->toBe($this->tenant->id); }); test('rejeita full_name sem owner/repo', function (): void { @@ -52,7 +59,7 @@ function mockGithubConnector(MockResponse $response): void ->assertHasFormErrors(['full_name']); }); -test('rejeita full_name duplicado', function (): void { +test('rejeita full_name duplicado no mesmo tenant', function (): void { GithubRepository::factory()->create(['full_name' => 'he4rt/4noobs']); livewire(CreateGithubRepository::class) @@ -61,6 +68,20 @@ function mockGithubConnector(MockResponse $response): void ->assertHasFormErrors(['full_name']); }); +test('lista apenas os repositórios do tenant atual', function (): void { + $mine = GithubRepository::factory()->create(['full_name' => 'he4rt/mine']); + + $other = Tenant::factory()->create(['slug' => 'outra']); + Filament::setTenant($other); + $theirs = GithubRepository::factory()->create(['full_name' => 'he4rt/theirs']); + Filament::setTenant($this->tenant); + + livewire(ListGithubRepositories::class) + ->loadTable() + ->assertCanSeeTableRecords([$mine]) + ->assertCanNotSeeTableRecords([$theirs]); +}); + test('backfill pelo painel avisa de forma amigável ao bater rate limit, sem marcar last_backfilled_at', function (): void { mockGithubConnector(MockResponse::make( ['message' => 'API rate limit exceeded'], diff --git a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php index 7fefbf9fa..87db0f0bd 100644 --- a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php +++ b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php @@ -6,6 +6,7 @@ use Carbon\CarbonImmutable; use Carbon\CarbonInterface; +use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Portal\Retrospective\CommunityRetrospective; use Illuminate\Contracts\View\View; use Livewire\Attributes\Layout; @@ -17,12 +18,27 @@ #[Title('Quem fez a He4rt bater')] final class CommunityRetrospectivePage extends Component { + public string $tenantId; + + public string $tenantName; + #[Url] public ?string $since = null; #[Url] public ?string $until = null; + public function mount(?string $tenantSlug = null): void + { + $slug = $tenantSlug ?? config()->string('he4rt.main_tenant'); + + /** @var Tenant $tenant */ + $tenant = Tenant::query()->where('slug', $slug)->firstOrFail(); + + $this->tenantId = $tenant->id; + $this->tenantName = $tenant->name; + } + public function render(): View { $since = $this->since !== null && $this->since !== '' @@ -34,7 +50,7 @@ public function render(): View : CarbonImmutable::now(); return view('portal::community-retrospective', [ - 'data' => new CommunityRetrospective($since, $until)->build(), + 'data' => new CommunityRetrospective($this->tenantId, $since, $until)->build(), 'sinceValue' => $since->toDateString(), 'untilValue' => $until->toDateString(), ]); diff --git a/app-modules/portal/src/PortalServiceProvider.php b/app-modules/portal/src/PortalServiceProvider.php index 00e43c29f..de96a60af 100644 --- a/app-modules/portal/src/PortalServiceProvider.php +++ b/app-modules/portal/src/PortalServiceProvider.php @@ -19,6 +19,7 @@ public function boot(): void { Route::get('/', Homepage::class); Route::get('/comunidade/retrospectiva', CommunityRetrospectivePage::class)->name('community.retrospective'); + Route::get('/comunidade/{tenantSlug}/retrospectiva', CommunityRetrospectivePage::class)->name('community.retrospective.tenant'); Livewire::component('hero-section', HeroSection::class); } diff --git a/app-modules/portal/src/Retrospective/CommunityRetrospective.php b/app-modules/portal/src/Retrospective/CommunityRetrospective.php index 0606b0b7b..c9dbf06f3 100644 --- a/app-modules/portal/src/Retrospective/CommunityRetrospective.php +++ b/app-modules/portal/src/Retrospective/CommunityRetrospective.php @@ -18,6 +18,7 @@ final readonly class CommunityRetrospective { public function __construct( + private string $tenantId, private CarbonInterface $since, private CarbonInterface $until, ) {} @@ -33,6 +34,7 @@ public function build(): array { /** @var Collection $contributions */ $contributions = GithubContribution::query() + ->where('tenant_id', $this->tenantId) ->whereBetween('occurred_at', [$this->since, $this->until]) ->get() ->reject(fn (GithubContribution $contribution): bool => $this->isBot($contribution)) diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php index 5ca8590e5..f2c366bca 100644 --- a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php +++ b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php @@ -3,14 +3,20 @@ declare(strict_types=1); use Carbon\CarbonImmutable; +use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationGithub\Enums\ContributionType; use He4rt\IntegrationGithub\Models\GithubContribution; use He4rt\Portal\Livewire\CommunityRetrospectivePage; use function Pest\Livewire\livewire; +beforeEach(function (): void { + config(['he4rt.main_tenant' => 'he4rt']); + $this->tenant = Tenant::factory()->create(['slug' => 'he4rt']); +}); + it('mostra os contribuidores do período informado', function (): void { - GithubContribution::factory()->create([ + GithubContribution::factory()->for($this->tenant)->create([ 'actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false], ]); @@ -23,7 +29,7 @@ it('usa a janela padrão (segunda passada → hoje) quando sem parâmetros', function (): void { $this->travelTo(CarbonImmutable::parse('2026-06-04 10:00:00')); - GithubContribution::factory()->create([ + GithubContribution::factory()->for($this->tenant)->create([ 'actor_login' => 'joao', 'actor_id' => 7, 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-06-02', ]); @@ -33,12 +39,16 @@ ->assertSee('joao'); }); -it('responde na rota pública /comunidade/retrospectiva', function (): void { +it('responde na rota pública /comunidade/retrospectiva (tenant principal)', function (): void { test()->get('/comunidade/retrospectiva')->assertOk(); }); +it('responde na rota com slug /comunidade/{tenant}/retrospectiva', function (): void { + test()->get('/comunidade/he4rt/retrospectiva')->assertOk(); +}); + it('inclui e marca contribuidor cujo único PR foi fechado sem merge', function (): void { - GithubContribution::factory()->create([ + GithubContribution::factory()->for($this->tenant)->create([ 'actor_login' => 'rejeitada', 'actor_id' => 99, 'type' => ContributionType::Pr, 'external_ref' => 'pr:5', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => false], ]); diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php index eb835a341..b9cfd1ead 100644 --- a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php +++ b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php @@ -3,21 +3,31 @@ declare(strict_types=1); use Carbon\CarbonImmutable; +use He4rt\Identity\Tenant\Models\Tenant; use He4rt\IntegrationGithub\Enums\ContributionType; use He4rt\IntegrationGithub\Models\GithubContribution; use He4rt\Portal\Retrospective\CommunityRetrospective; beforeEach(function (): void { + $this->tenant = Tenant::factory()->create(); $this->since = CarbonImmutable::parse('2026-06-01 00:00:00'); $this->until = CarbonImmutable::parse('2026-06-07 23:59:59'); }); +/** + * @param array $attributes + */ +function contribution(Tenant $tenant, array $attributes): GithubContribution +{ + return GithubContribution::factory()->for($tenant)->create($attributes); +} + it('agrega contribuições por pessoa com contagem por tipo e total, ordenado desc', function (): void { - GithubContribution::factory()->create(['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); - GithubContribution::factory()->create(['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-06-03']); - GithubContribution::factory()->create(['actor_login' => 'joao', 'actor_id' => 7, 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-03']); + contribution($this->tenant, ['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + contribution($this->tenant, ['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-06-03']); + contribution($this->tenant, ['actor_login' => 'joao', 'actor_id' => 7, 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-03']); - $data = new CommunityRetrospective($this->since, $this->until)->build(); + $data = new CommunityRetrospective($this->tenant->id, $this->since, $this->until)->build(); expect($data['meta']['people'])->toBe(2) ->and($data['meta']['total'])->toBe(3) @@ -29,22 +39,31 @@ }); it('exclui bots do ranking', function (): void { - GithubContribution::factory()->create(['actor_login' => 'dependabot[bot]', 'type' => ContributionType::Pr, 'external_ref' => 'pr:9', 'occurred_at' => '2026-06-02', 'metadata' => ['is_bot' => true, 'state' => 'open', 'merged' => false]]); - GithubContribution::factory()->create(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + contribution($this->tenant, ['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($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + + $data = new CommunityRetrospective($this->tenant->id, $this->since, $this->until)->build(); + + expect($data['meta']['people'])->toBe(1) + ->and($data['people'][0]['login'])->toBe('maria'); +}); + +it('isola por tenant: ignora contribuições de outra comunidade', function (): void { + contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + contribution(Tenant::factory()->create(), ['actor_login' => 'estranho', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); - $data = new CommunityRetrospective($this->since, $this->until)->build(); + $data = new CommunityRetrospective($this->tenant->id, $this->since, $this->until)->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 { - // a mesma pessoa com os três desfechos: fechado-sem-merge, merged e aberto - GithubContribution::factory()->create(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => false]]); - GithubContribution::factory()->create(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => true]]); - GithubContribution::factory()->create(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:3', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + contribution($this->tenant, ['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => false]]); + contribution($this->tenant, ['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => true]]); + contribution($this->tenant, ['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:3', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); - $data = new CommunityRetrospective($this->since, $this->until)->build(); + $data = new CommunityRetrospective($this->tenant->id, $this->since, $this->until)->build(); $person = collect($data['people'])->firstWhere('login', 'a'); @@ -58,10 +77,10 @@ }); it('respeita a janela de período pelo occurred_at', function (): void { - GithubContribution::factory()->create(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-05-30']); - GithubContribution::factory()->create(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:2', 'occurred_at' => '2026-06-03']); + contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-05-30']); + contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:2', 'occurred_at' => '2026-06-03']); - $data = new CommunityRetrospective($this->since, $this->until)->build(); + $data = new CommunityRetrospective($this->tenant->id, $this->since, $this->until)->build(); expect($data['meta']['total'])->toBe(1) ->and($data['meta']['issues'])->toBe(1); diff --git a/config/he4rt.php b/config/he4rt.php index 67025ca8a..89afe3957 100644 --- a/config/he4rt.php +++ b/config/he4rt.php @@ -6,6 +6,7 @@ return [ 'admins' => env('HE4RT_ADMINS_USERNAMES', 'danielhe4rt,kaster'), + 'main_tenant' => env('HE4RT_MAIN_TENANT', 'he4rt'), 'season' => [ 'id' => (int) env('HE4RT_SEASON_ID', 2), 'minimum_level_for_retro' => env('HE4RT_SEASON_MIN_LEVEL', 3), From d03f1c85775a4917620f5ff30de79235fcf7d7cf Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Fri, 5 Jun 2026 23:09:22 -0300 Subject: [PATCH 03/27] feat(portal): RetrospectiveFilters DTO para o read model da retrospectiva --- .../Retrospective/RetrospectiveFilters.php | 68 +++++++++++++++++++ .../Feature/RetrospectiveFiltersTest.php | 35 ++++++++++ 2 files changed, 103 insertions(+) create mode 100644 app-modules/portal/src/Retrospective/RetrospectiveFilters.php create mode 100644 app-modules/portal/tests/Feature/RetrospectiveFiltersTest.php diff --git a/app-modules/portal/src/Retrospective/RetrospectiveFilters.php b/app-modules/portal/src/Retrospective/RetrospectiveFilters.php new file mode 100644 index 000000000..c39c4c26f --- /dev/null +++ b/app-modules/portal/src/Retrospective/RetrospectiveFilters.php @@ -0,0 +1,68 @@ + $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'], true) ? $outcome : null, + person: ($person === null || $person === '') ? null : $person, + hideBots: $hideBots, + sort: in_array($sort, ['total', 'prs', 'lines'], true) ? $sort : 'total', + ); + } +} diff --git a/app-modules/portal/tests/Feature/RetrospectiveFiltersTest.php b/app-modules/portal/tests/Feature/RetrospectiveFiltersTest.php new file mode 100644 index 000000000..b9166c00c --- /dev/null +++ b/app-modules/portal/tests/Feature/RetrospectiveFiltersTest.php @@ -0,0 +1,35 @@ +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'); +}); From fabf166e77e376e48762e32170a288238d235514 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Fri, 5 Jun 2026 23:11:21 -0300 Subject: [PATCH 04/27] refactor(portal): CommunityRetrospective recebe RetrospectiveFilters --- .../src/Retrospective/CommunityRetrospective.php | 13 +++++++------ .../tests/Feature/CommunityRetrospectiveTest.php | 15 ++++++++++----- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/app-modules/portal/src/Retrospective/CommunityRetrospective.php b/app-modules/portal/src/Retrospective/CommunityRetrospective.php index c9dbf06f3..d65ae8435 100644 --- a/app-modules/portal/src/Retrospective/CommunityRetrospective.php +++ b/app-modules/portal/src/Retrospective/CommunityRetrospective.php @@ -4,7 +4,6 @@ namespace He4rt\Portal\Retrospective; -use Carbon\CarbonInterface; use He4rt\IntegrationGithub\Enums\ContributionType; use He4rt\IntegrationGithub\Models\GithubContribution; use Illuminate\Support\Collection; @@ -19,8 +18,7 @@ { public function __construct( private string $tenantId, - private CarbonInterface $since, - private CarbonInterface $until, + private RetrospectiveFilters $filters, ) {} /** @@ -35,9 +33,12 @@ public function build(): array /** @var Collection $contributions */ $contributions = GithubContribution::query() ->where('tenant_id', $this->tenantId) - ->whereBetween('occurred_at', [$this->since, $this->until]) + ->whereBetween('occurred_at', [$this->filters->since, $this->filters->until]) ->get() - ->reject(fn (GithubContribution $contribution): bool => $this->isBot($contribution)) + ->when( + $this->filters->hideBots, + fn (Collection $items): Collection => $items->reject(fn (GithubContribution $contribution): bool => $this->isBot($contribution)), + ) ->values(); /** @var list> $people */ @@ -49,7 +50,7 @@ public function build(): array ->all(); return [ - 'period' => ['since' => $this->since->toDateString(), 'until' => $this->until->toDateString()], + 'period' => ['since' => $this->filters->since->toDateString(), 'until' => $this->filters->until->toDateString()], 'meta' => [ 'people' => count($people), 'prs' => $this->countType($contributions, ContributionType::Pr), diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php index b9cfd1ead..7d91a934b 100644 --- a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php +++ b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php @@ -7,11 +7,16 @@ use He4rt\IntegrationGithub\Enums\ContributionType; use He4rt\IntegrationGithub\Models\GithubContribution; use He4rt\Portal\Retrospective\CommunityRetrospective; +use He4rt\Portal\Retrospective\RetrospectiveFilters; beforeEach(function (): void { $this->tenant = Tenant::factory()->create(); $this->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( + $this->tenant->id, + $filters ?? RetrospectiveFilters::period($this->since, $this->until), + )->build(); }); /** @@ -27,7 +32,7 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution contribution($this->tenant, ['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-06-03']); contribution($this->tenant, ['actor_login' => 'joao', 'actor_id' => 7, 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-03']); - $data = new CommunityRetrospective($this->tenant->id, $this->since, $this->until)->build(); + $data = ($this->build)(); expect($data['meta']['people'])->toBe(2) ->and($data['meta']['total'])->toBe(3) @@ -42,7 +47,7 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution contribution($this->tenant, ['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($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); - $data = new CommunityRetrospective($this->tenant->id, $this->since, $this->until)->build(); + $data = ($this->build)(); expect($data['meta']['people'])->toBe(1) ->and($data['people'][0]['login'])->toBe('maria'); @@ -52,7 +57,7 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); contribution(Tenant::factory()->create(), ['actor_login' => 'estranho', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); - $data = new CommunityRetrospective($this->tenant->id, $this->since, $this->until)->build(); + $data = ($this->build)(); expect($data['meta']['people'])->toBe(1) ->and($data['people'][0]['login'])->toBe('maria'); @@ -63,7 +68,7 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution contribution($this->tenant, ['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => true]]); contribution($this->tenant, ['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:3', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); - $data = new CommunityRetrospective($this->tenant->id, $this->since, $this->until)->build(); + $data = ($this->build)(); $person = collect($data['people'])->firstWhere('login', 'a'); @@ -80,7 +85,7 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-05-30']); contribution($this->tenant, ['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:2', 'occurred_at' => '2026-06-03']); - $data = new CommunityRetrospective($this->tenant->id, $this->since, $this->until)->build(); + $data = ($this->build)(); expect($data['meta']['total'])->toBe(1) ->and($data['meta']['issues'])->toBe(1); From 3549855df8df62f46321af88b02cf3e166a245e1 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Fri, 5 Jun 2026 23:12:51 -0300 Subject: [PATCH 05/27] =?UTF-8?q?feat(portal):=20agrega=20volume=20de=20c?= =?UTF-8?q?=C3=B3digo=20(add/del/files)=20na=20retrospectiva?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Retrospective/CommunityRetrospective.php | 17 +++++++++++++++++ .../Feature/CommunityRetrospectiveTest.php | 15 +++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/app-modules/portal/src/Retrospective/CommunityRetrospective.php b/app-modules/portal/src/Retrospective/CommunityRetrospective.php index d65ae8435..862cf084e 100644 --- a/app-modules/portal/src/Retrospective/CommunityRetrospective.php +++ b/app-modules/portal/src/Retrospective/CommunityRetrospective.php @@ -60,6 +60,9 @@ public function build(): array 'issues' => $this->countType($contributions, ContributionType::Issue), 'comments' => $this->countType($contributions, ContributionType::Comment), 'commits' => $this->countType($contributions, ContributionType::Commit), + 'additions' => $this->sumMeta($contributions, 'additions'), + 'deletions' => $this->sumMeta($contributions, 'deletions'), + 'changed_files' => $this->sumMeta($contributions, 'changed_files'), 'total' => $contributions->count(), ], 'people' => $people, @@ -87,6 +90,8 @@ private function person(string $login, Collection $items): array 'issues' => $this->countType($items, ContributionType::Issue), 'comments' => $this->countType($items, ContributionType::Comment), 'commits' => $this->countType($items, ContributionType::Commit), + 'additions' => $this->sumMeta($items, 'additions'), + 'deletions' => $this->sumMeta($items, 'deletions'), 'total' => $items->count(), ]; } @@ -99,6 +104,18 @@ private function countType(Collection $items, ContributionType $type): int return $items->filter(fn (GithubContribution $contribution): bool => $contribution->type === $type)->count(); } + /** + * @param Collection $items + */ + private function sumMeta(Collection $items, string $key): int + { + return (int) $items->sum(function (GithubContribution $contribution) use ($key): int { + $metadata = $contribution->metadata ?? []; + + return (int) ($metadata[$key] ?? 0); + }); + } + private function isBot(GithubContribution $contribution): bool { $metadata = $contribution->metadata ?? []; diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php index 7d91a934b..c35159884 100644 --- a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php +++ b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php @@ -90,3 +90,18 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution 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($this->tenant, ['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($this->tenant, ['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($this->tenant, ['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); +}); From 6065fd9f2b4e4cf09c83976f2d5fc84231780b87 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Fri, 5 Jun 2026 23:14:28 -0300 Subject: [PATCH 06/27] =?UTF-8?q?feat(portal):=20exp=C3=B5e=20refs=20de=20?= =?UTF-8?q?PR/issue=20por=20pessoa=20na=20retrospectiva?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Retrospective/CommunityRetrospective.php | 55 +++++++++++++++++++ .../Feature/CommunityRetrospectiveTest.php | 14 +++++ 2 files changed, 69 insertions(+) diff --git a/app-modules/portal/src/Retrospective/CommunityRetrospective.php b/app-modules/portal/src/Retrospective/CommunityRetrospective.php index 862cf084e..fdc48b1af 100644 --- a/app-modules/portal/src/Retrospective/CommunityRetrospective.php +++ b/app-modules/portal/src/Retrospective/CommunityRetrospective.php @@ -92,6 +92,8 @@ private function person(string $login, Collection $items): array 'commits' => $this->countType($items, ContributionType::Commit), 'additions' => $this->sumMeta($items, 'additions'), 'deletions' => $this->sumMeta($items, 'deletions'), + 'pr_refs' => $this->prRefs($items), + 'issue_refs' => $this->issueRefs($items), 'total' => $items->count(), ]; } @@ -116,6 +118,59 @@ private function sumMeta(Collection $items, string $key): int }); } + /** + * @param Collection $items + * @return list + */ + private function prRefs(Collection $items): array + { + return $items + ->filter(fn (GithubContribution $contribution): bool => $contribution->type === ContributionType::Pr) + ->map(function (GithubContribution $contribution): array { + $metadata = $contribution->metadata ?? []; + $url = $metadata['url'] ?? null; + $state = $metadata['state'] ?? null; + + return [ + 'num' => $this->refNumber($contribution->external_ref), + 'title' => (string) ($metadata['title'] ?? ''), + 'url' => is_string($url) ? $url : null, + 'state' => is_string($state) ? $state : null, + ]; + }) + ->values() + ->all(); + } + + /** + * @param Collection $items + * @return list + */ + private function issueRefs(Collection $items): array + { + return $items + ->filter(fn (GithubContribution $contribution): bool => $contribution->type === ContributionType::Issue) + ->map(function (GithubContribution $contribution): array { + $metadata = $contribution->metadata ?? []; + $url = $metadata['url'] ?? null; + + return [ + 'num' => $this->refNumber($contribution->external_ref), + 'title' => (string) ($metadata['title'] ?? ''), + 'url' => is_string($url) ? $url : null, + ]; + }) + ->values() + ->all(); + } + + private function refNumber(string $externalRef): int + { + $parts = explode(':', $externalRef); + + return (int) ($parts[1] ?? 0); + } + private function isBot(GithubContribution $contribution): bool { $metadata = $contribution->metadata ?? []; diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php index c35159884..10b5caaa5 100644 --- a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php +++ b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php @@ -105,3 +105,17 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution ->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($this->tenant, ['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($this->tenant, ['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']); +}); From 863300b46e964ab28ede06ffb2913cc6925c8199 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Fri, 5 Jun 2026 23:16:35 -0300 Subject: [PATCH 07/27] feat(portal): agrupa retrospectiva por repo e calcula destaques --- .../Retrospective/CommunityRetrospective.php | 90 ++++++++++++++++++- .../Feature/CommunityRetrospectiveTest.php | 14 +++ 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/app-modules/portal/src/Retrospective/CommunityRetrospective.php b/app-modules/portal/src/Retrospective/CommunityRetrospective.php index fdc48b1af..1cabdadfb 100644 --- a/app-modules/portal/src/Retrospective/CommunityRetrospective.php +++ b/app-modules/portal/src/Retrospective/CommunityRetrospective.php @@ -26,6 +26,8 @@ public function __construct( * period: array{since: string, until: string}, * meta: array, * people: list>, + * repos: list>, + * highlights: list>, * } */ public function build(): array @@ -66,6 +68,8 @@ public function build(): array 'total' => $contributions->count(), ], 'people' => $people, + 'repos' => $this->repos($contributions), + 'highlights' => $this->highlights($contributions), ]; } @@ -79,9 +83,7 @@ private function person(string $login, Collection $items): array return [ 'login' => $login, - 'avatar' => $actorId !== null - ? 'https://avatars.githubusercontent.com/u/'.$actorId.'?v=4' - : 'https://github.com/'.$login.'.png', + 'avatar' => $this->avatar($login, $actorId), 'url' => 'https://github.com/'.$login, 'prs' => $this->countType($items, ContributionType::Pr), 'prs_merged' => $this->countMergedPrs($items), @@ -171,6 +173,88 @@ private function refNumber(string $externalRef): int return (int) ($parts[1] ?? 0); } + private function avatar(string $login, ?int $actorId): string + { + return $actorId !== null + ? 'https://avatars.githubusercontent.com/u/'.$actorId.'?v=4' + : 'https://github.com/'.$login.'.png'; + } + + /** + * @return array{num: int, title: string, url: string|null, state: string|null, author_login: string, additions: int, deletions: int, changed_files: int} + */ + private function prRow(GithubContribution $contribution): array + { + $metadata = $contribution->metadata ?? []; + $url = $metadata['url'] ?? null; + $state = $metadata['state'] ?? null; + + return [ + 'num' => $this->refNumber($contribution->external_ref), + 'title' => (string) ($metadata['title'] ?? ''), + 'url' => is_string($url) ? $url : null, + 'state' => is_string($state) ? $state : null, + 'author_login' => $contribution->actor_login, + 'additions' => (int) ($metadata['additions'] ?? 0), + 'deletions' => (int) ($metadata['deletions'] ?? 0), + 'changed_files' => (int) ($metadata['changed_files'] ?? 0), + ]; + } + + /** + * @param Collection $contributions + * @return list> + */ + private function repos(Collection $contributions): array + { + return $contributions + ->groupBy('repo') + ->map(function (Collection $items, string $repo): array { + $prs = $items->filter(fn (GithubContribution $contribution): bool => $contribution->type === ContributionType::Pr); + + return [ + 'full_name' => $repo, + 'name' => explode('/', $repo)[1] ?? $repo, + 'prs' => $prs + ->map(fn (GithubContribution $contribution): array => $this->prRow($contribution)) + ->sortByDesc(fn (array $pr): int => $pr['additions'] + $pr['deletions']) + ->values() + ->all(), + 'people' => $items + ->groupBy('actor_login') + ->map(fn (Collection $group, string $login): array => [ + 'login' => $login, + 'avatar' => $this->avatar($login, $group->first()?->actor_id), + ]) + ->values() + ->all(), + 'metrics' => [ + 'prs' => $prs->count(), + 'additions' => $this->sumMeta($prs, 'additions'), + 'deletions' => $this->sumMeta($prs, 'deletions'), + 'changed_files' => $this->sumMeta($prs, 'changed_files'), + ], + ]; + }) + ->values() + ->all(); + } + + /** + * @param Collection $contributions + * @return list> + */ + private function highlights(Collection $contributions): array + { + return $contributions + ->filter(fn (GithubContribution $contribution): bool => $contribution->type === ContributionType::Pr) + ->map(fn (GithubContribution $contribution): array => [...$this->prRow($contribution), 'repo' => $contribution->repo]) + ->sortByDesc(fn (array $pr): int => $pr['additions'] + $pr['deletions']) + ->take(4) + ->values() + ->all(); + } + private function isBot(GithubContribution $contribution): bool { $metadata = $contribution->metadata ?? []; diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php index 10b5caaa5..04ab5823f 100644 --- a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php +++ b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php @@ -119,3 +119,17 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution ->and($maria['issue_refs'])->toHaveCount(1) ->and($maria['issue_refs'][0])->toMatchArray(['num' => 12, 'title' => 'bug: y']); }); + +it('agrupa PRs por repositório e lista destaques por linhas mudadas', function (): void { + contribution($this->tenant, ['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($this->tenant, ['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'); +}); From d1161ea8521aaee534b27030f34a6d50a0e75fd5 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Fri, 5 Jun 2026 23:19:17 -0300 Subject: [PATCH 08/27] =?UTF-8?q?feat(portal):=20filtros=20de=20tipo/repo/?= =?UTF-8?q?desfecho/pessoa=20e=20ordena=C3=A7=C3=A3o=20na=20retrospectiva?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Retrospective/CommunityRetrospective.php | 33 ++++++++++++++++++- .../Feature/CommunityRetrospectiveTest.php | 24 ++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/app-modules/portal/src/Retrospective/CommunityRetrospective.php b/app-modules/portal/src/Retrospective/CommunityRetrospective.php index 1cabdadfb..ab881d671 100644 --- a/app-modules/portal/src/Retrospective/CommunityRetrospective.php +++ b/app-modules/portal/src/Retrospective/CommunityRetrospective.php @@ -41,13 +41,27 @@ public function build(): array $this->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, true)), + ) + ->filter(fn (GithubContribution $contribution): bool => in_array($contribution->type, $this->filters->types, 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(); /** @var list> $people */ $people = $contributions ->groupBy('actor_login') ->map(fn (Collection $items, string $login): array => $this->person($login, $items)) - ->sortByDesc('total') + ->sortByDesc(fn (array $person): int => match ($this->filters->sort) { + 'prs' => (int) $person['prs'] * 1000 + (int) $person['total'], + 'lines' => (int) $person['additions'] + (int) $person['deletions'], + default => (int) $person['total'], + }) ->values() ->all(); @@ -180,6 +194,23 @@ 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/portal/tests/Feature/CommunityRetrospectiveTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php index 04ab5823f..faf4ca88b 100644 --- a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php +++ b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php @@ -133,3 +133,27 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution ->and($data['highlights'][0]['additions'])->toBe(500) ->and($data['highlights'][0]['repo'])->toBe('he4rt/heartdevs.com'); }); + +it('aplica filtros de tipo, repo, desfecho e pessoa', function (): void { + contribution($this->tenant, ['actor_login' => 'maria', 'repo' => 'he4rt/a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true]]); + contribution($this->tenant, ['actor_login' => 'maria', 'repo' => 'he4rt/a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + contribution($this->tenant, ['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('ordena o ranking por linhas quando sort=lines', function (): void { + contribution($this->tenant, ['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($this->tenant, ['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'); +}); From 262a3d626d65b1c0d15543db942dc36ac924bbbe Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Fri, 5 Jun 2026 23:20:53 -0300 Subject: [PATCH 09/27] =?UTF-8?q?fix(portal):=20p=C3=A1gina=20da=20retrosp?= =?UTF-8?q?ectiva=20usa=20RetrospectiveFilters=20(caller)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app-modules/portal/src/Livewire/CommunityRetrospectivePage.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php index 87db0f0bd..d072b0074 100644 --- a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php +++ b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php @@ -8,6 +8,7 @@ use Carbon\CarbonInterface; use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Portal\Retrospective\CommunityRetrospective; +use He4rt\Portal\Retrospective\RetrospectiveFilters; use Illuminate\Contracts\View\View; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; @@ -50,7 +51,7 @@ public function render(): View : CarbonImmutable::now(); return view('portal::community-retrospective', [ - 'data' => new CommunityRetrospective($this->tenantId, $since, $until)->build(), + 'data' => new CommunityRetrospective($this->tenantId, RetrospectiveFilters::period($since, $until))->build(), 'sinceValue' => $since->toDateString(), 'untilValue' => $until->toDateString(), ]); From 1b15fcb0e28edb5c5352ddd4fe520de8e33ea3e9 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Fri, 5 Jun 2026 23:23:28 -0300 Subject: [PATCH 10/27] feat(portal): design system (CSS) do deck da retrospectiva --- .../portal/resources/css/retrospective.css | 862 ++++++++++++++++++ vite.config.js | 1 + 2 files changed, 863 insertions(+) create mode 100644 app-modules/portal/resources/css/retrospective.css diff --git a/app-modules/portal/resources/css/retrospective.css b/app-modules/portal/resources/css/retrospective.css new file mode 100644 index 000000000..531e7dbef --- /dev/null +++ b/app-modules/portal/resources/css/retrospective.css @@ -0,0 +1,862 @@ +/* + | Design system do deck da retrospectiva — escopado sob `.retro` para não vazar + | para o resto do portal. Estética: roxo cósmico He4rt, Fraunces/Hanken/JetBrains. + */ +@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;0,9..144,700;1,9..144,500;1,9..144,600&family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap'); + +.retro { + --bg: #0b0a10; + --surface: #15131c; + --surface-2: #1c1926; + --border: #2c2838; + --text: #f4f2f8; + --muted: #9c95ab; + --faint: #6b6379; + --brand: #782bf1; + --brand-soft: #b69bff; + --brand-2: #c44bff; + --t-pr: #ff5d8f; + --t-issue: #f6b73c; + --t-commit: #b39bff; + --t-review: #2dd4bf; + --t-comment: #62a6ff; + --st-merged: #a06bff; + --st-open: #34d399; + --st-closed: #f87171; + --add: #4ade80; + --del: #f87171; + + position: fixed; + inset: 0; + background: var(--bg); + color: var(--text); + font-family: 'Hanken Grotesk', sans-serif; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + overflow: hidden; +} + +.retro *, +.retro *::before, +.retro *::after { + box-sizing: border-box; +} + +.retro .mono { + font-family: 'JetBrains Mono', monospace; +} +.retro .display { + font-family: 'Fraunces', serif; + font-optical-sizing: auto; +} + +/* atmosfera */ +.retro .atmo { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; +} +.retro .atmo::before { + content: ''; + position: absolute; + top: -22%; + left: 50%; + transform: translateX(-50%); + width: 120vw; + height: 80vh; + background: radial-gradient( + ellipse at center, + rgba(120, 43, 241, 0.2), + rgba(196, 75, 255, 0.05) 42%, + transparent 72% + ); + filter: blur(10px); +} +.retro .grain { + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + opacity: 0.05; + mix-blend-mode: overlay; +} + +/* progress bar */ +.retro .progress { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--border); + z-index: 6; +} +.retro .bar { + height: 100%; + width: 0; + background: linear-gradient(90deg, var(--brand), var(--brand-2)); + transition: width 0.5s ease; + box-shadow: 0 0 10px rgba(120, 43, 241, 0.6); +} + +/* deck + slides */ +.retro .deck { + position: absolute; + inset: 0; + z-index: 2; + overflow: hidden; +} +.retro .slide { + position: absolute; + inset: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + opacity: 0; + visibility: hidden; + transform: translateX(38px) scale(0.99); + transition: + opacity 0.55s ease, + transform 0.6s cubic-bezier(0.2, 0.7, 0.2, 1), + visibility 0.55s; +} +.retro .slide.active { + opacity: 1; + visibility: visible; + transform: none; +} +.retro .slide.past { + transform: translateX(-38px) scale(0.99); +} +.retro .slide-inner { + margin: auto; + width: 100%; + max-width: 1120px; + padding: 84px 34px 116px; +} +.retro .slide [data-anim] { + opacity: 0; +} +.retro .slide.active [data-anim] { + animation: retro-rise 0.62s cubic-bezier(0.2, 0.7, 0.2, 1) both; +} +@keyframes retro-rise { + from { + opacity: 0; + transform: translateY(26px); + } + to { + opacity: 1; + transform: none; + } +} + +/* tipografia de slide */ +.retro .kicker { + font-family: 'JetBrains Mono', monospace; + font-size: 0.74rem; + letter-spacing: 0.26em; + text-transform: uppercase; + color: var(--brand-soft); +} +.retro .hero { + font-family: 'Fraunces', serif; + font-weight: 600; + line-height: 0.96; + font-size: clamp(2.6rem, 7.5vw, 5.4rem); + letter-spacing: -0.02em; + margin: 0.2em 0 0; +} +.retro .hero em { + font-style: italic; + font-weight: 500; + background: linear-gradient(105deg, var(--brand-soft), var(--brand-2)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + padding-right: 0.05em; +} +.retro .lead { + font-size: clamp(1.02rem, 1.6vw, 1.26rem); + color: var(--muted); + max-width: 60ch; +} +.retro .lead b { + color: var(--text); + font-weight: 600; +} +.retro .num { + color: var(--brand-soft); + font-weight: 600; + font-family: 'JetBrains Mono', monospace; +} +.retro .ecg { + width: 100%; + max-width: 560px; + height: 60px; + display: block; + margin: 14px auto 6px; +} +.retro .ecg path { + fill: none; + stroke: var(--brand); + stroke-width: 2.4; + stroke-linecap: round; + stroke-linejoin: round; + filter: drop-shadow(0 0 7px rgba(120, 43, 241, 0.85)); + stroke-dasharray: 1500; + stroke-dashoffset: 1500; +} +.retro .slide.active .ecg path { + animation: retro-draw 1.8s 0.5s ease-out forwards; +} +@keyframes retro-draw { + to { + stroke-dashoffset: 0; + } +} +.retro .hint { + font-family: 'JetBrains Mono', monospace; + font-size: 0.78rem; + color: var(--faint); + margin-top: 28px; +} +.retro .hint kbd { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 5px; + padding: 1px 7px; + color: var(--brand-soft); +} + +.retro .sec-tag { + font-family: 'JetBrains Mono', monospace; + font-size: 0.72rem; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--brand-soft); + display: inline-flex; + align-items: center; + gap: 9px; +} +.retro .sec-tag::before { + content: ''; + width: 26px; + height: 1px; + background: var(--brand); + display: inline-block; +} +.retro .sec { + font-family: 'Fraunces', serif; + font-weight: 600; + font-size: clamp(1.9rem, 4vw, 2.8rem); + letter-spacing: -0.01em; + margin: 0.3em 0 0.2em; + line-height: 1.04; +} +.retro .sec-sub { + color: var(--muted); + max-width: 68ch; + font-size: 1.04rem; +} + +/* stats */ +.retro .stats { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + border-radius: 18px; + overflow: hidden; +} +.retro .stat { + background: var(--surface); + padding: 22px 12px; + text-align: center; +} +.retro .stat .v { + font-family: 'Fraunces', serif; + font-weight: 600; + font-size: clamp(1.8rem, 3.4vw, 2.7rem); + line-height: 1; + color: var(--text); +} +.retro .stat .v.accent { + color: var(--brand-soft); +} +.retro .stat .l { + font-family: 'JetBrains Mono', monospace; + font-size: 0.64rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); + margin-top: 9px; +} + +/* card */ +.retro .card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 18px; + padding: 20px; + transition: + transform 0.35s cubic-bezier(0.2, 0.7, 0.2, 1), + border-color 0.35s, + box-shadow 0.35s; +} +.retro .card:hover { + transform: translateY(-3px); + border-color: rgba(120, 43, 241, 0.45); + box-shadow: 0 16px 38px -22px rgba(120, 43, 241, 0.55); +} + +/* badges */ +.retro .bdg { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.82rem; + font-weight: 700; + border-radius: 8px; + padding: 4px 10px; + white-space: nowrap; +} +.retro .bdg.add { + color: #7ef0a3; + background: rgba(74, 222, 128, 0.12); + border: 1px solid rgba(74, 222, 128, 0.34); +} +.retro .bdg.del { + color: #ff9a9a; + background: rgba(248, 113, 113, 0.12); + border: 1px solid rgba(248, 113, 113, 0.34); +} +.retro .bdg.neu { + color: var(--brand-soft); + background: rgba(120, 43, 241, 0.12); + border: 1px solid rgba(120, 43, 241, 0.32); +} + +.retro .szbar { + height: 7px; + border-radius: 999px; + overflow: hidden; + display: inline-flex; + background: var(--surface-2); + width: 120px; + flex: none; + border: 1px solid var(--border); + vertical-align: middle; +} +.retro .szbar span { + height: 100%; + display: block; +} +.retro .szbar .a { + background: var(--add); +} +.retro .szbar .d { + background: var(--del); +} + +/* pr row */ +.retro .tpr { + display: flex; + gap: 13px; + align-items: flex-start; + padding: 13px 15px; + border-radius: 13px; + background: var(--surface); + border: 1px solid var(--border); + text-decoration: none; + transition: 0.2s; +} +.retro .tpr:hover { + border-color: var(--brand); + background: rgba(120, 43, 241, 0.06); + transform: translateX(3px); +} +.retro .tpr .rn { + color: var(--brand-soft); + font-weight: 700; + font-family: 'JetBrains Mono', monospace; + font-size: 0.9rem; + flex: none; + padding-top: 1px; +} +.retro .tpr .d { + color: var(--text); + font-size: 0.98rem; + line-height: 1.4; +} +.retro .tpr .by { + color: var(--faint); + font-size: 0.8rem; + font-family: 'JetBrains Mono', monospace; +} +.retro .stdot { + width: 9px; + height: 9px; + border-radius: 50%; + flex: none; + margin-top: 6px; + display: inline-block; +} + +/* repo */ +.retro .repo-ic { + width: 54px; + height: 54px; + flex: none; + border-radius: 15px; + display: grid; + place-items: center; + background: rgba(120, 43, 241, 0.13); + border: 1px solid rgba(120, 43, 241, 0.32); +} +.retro .repo-ic svg { + width: 27px; + height: 27px; +} +.retro .repo-name { + font-family: 'Fraunces', serif; + font-weight: 600; + font-size: clamp(1.6rem, 3.2vw, 2.3rem); + line-height: 1.05; +} + +/* avatares */ +.retro .mini { + border-radius: 50%; + border: 2px solid var(--surface); + object-fit: cover; + background: var(--surface-2); + display: block; +} +.retro .avstack { + display: flex; +} +.retro .avstack .mini { + margin-left: -12px; +} +.retro .avstack .mini:first-child { + margin-left: 0; +} + +/* pessoa */ +.retro .phead { + display: flex; + align-items: center; + gap: 13px; +} +.retro .pavatar { + flex: none; + text-decoration: none; +} +.retro .name { + font-family: 'Fraunces', serif; + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.12; + color: var(--text); + text-decoration: none; +} +.retro .name:hover { + color: var(--brand-soft); +} +.retro .handle { + font-family: 'JetBrains Mono', monospace; + color: var(--faint); + font-size: 0.84rem; + text-decoration: none; +} +.retro .total-pill { + font-family: 'JetBrains Mono', monospace; + font-size: 0.84rem; + font-weight: 700; + color: var(--brand-soft); + background: rgba(120, 43, 241, 0.13); + border: 1px solid rgba(120, 43, 241, 0.35); + border-radius: 999px; + padding: 3px 10px; + white-space: nowrap; +} +.retro .acts { + margin-top: 14px; +} +.retro .act { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 8px 0; + border-top: 1px solid var(--border); +} +.retro .act-h { + flex: none; + width: 118px; + display: inline-flex; + align-items: center; + gap: 7px; + padding-top: 3px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.72rem; + letter-spacing: 0.03em; + text-transform: uppercase; + color: color-mix(in srgb, var(--c) 80%, white); +} +.retro .act-items { + display: flex; + flex-wrap: wrap; + gap: 6px; + flex: 1; + min-width: 0; +} +.retro .ref { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + text-decoration: none; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 3px 10px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.82rem; + color: var(--muted); + transition: 0.18s; +} +.retro .ref:hover { + border-color: var(--brand); + color: var(--text); + background: rgba(120, 43, 241, 0.1); +} +.retro .ref .rn { + color: var(--brand-soft); + font-weight: 700; +} +.retro .ref .rt { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 240px; + font-family: 'Hanken Grotesk', sans-serif; +} + +/* navbar do deck */ +.retro .navbar { + position: absolute; + left: 0; + right: 0; + bottom: 0; + z-index: 6; + display: flex; + align-items: center; + gap: 16px; + padding: 13px 22px; + backdrop-filter: blur(14px); + background: linear-gradient(0deg, rgba(11, 10, 16, 0.94), rgba(11, 10, 16, 0.35)); + border-top: 1px solid var(--border); +} +.retro .counter { + font-family: 'JetBrains Mono', monospace; + font-size: 0.8rem; + color: var(--muted); + white-space: nowrap; +} +.retro .counter b { + color: var(--brand-soft); +} +.retro .counter .slabel { + color: var(--text); +} +.retro .dots { + display: flex; + gap: 9px; + margin: 0 auto; + flex-wrap: wrap; + justify-content: center; + max-width: 60%; +} +.retro .dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--border); + cursor: pointer; + transition: 0.25s; + border: none; + padding: 0; +} +.retro .dot:hover { + background: var(--brand-soft); +} +.retro .dot.on { + background: var(--brand); + transform: scale(1.3); + box-shadow: 0 0 10px var(--brand); +} +.retro .navbtn { + width: 42px; + height: 42px; + flex: none; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + cursor: pointer; + transition: 0.2s; + display: grid; + place-items: center; +} +.retro .navbtn svg { + width: 18px; + height: 18px; +} +.retro .navbtn:hover { + border-color: var(--brand); + background: rgba(120, 43, 241, 0.14); + color: var(--brand-soft); +} +.retro .navbtn:disabled { + opacity: 0.28; + cursor: default; +} + +/* filter trigger */ +.retro .filter-fab { + position: absolute; + top: 14px; + right: 18px; + z-index: 7; + display: inline-flex; + align-items: center; + gap: 8px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.76rem; + color: var(--text); + cursor: pointer; + background: rgba(21, 19, 28, 0.82); + backdrop-filter: blur(16px); + border: 1px solid var(--border); + border-radius: 999px; + padding: 9px 15px; + transition: 0.2s; +} +.retro .filter-fab:hover { + border-color: var(--brand); + color: var(--brand-soft); +} +.retro .filter-fab svg { + width: 15px; + height: 15px; +} + +/* painel de filtros (slide-over) */ +.retro .scrim { + position: absolute; + inset: 0; + z-index: 9; + background: rgba(5, 4, 9, 0.6); + opacity: 0; + visibility: hidden; + transition: 0.3s; +} +.retro .scrim.open { + opacity: 1; + visibility: visible; +} +.retro .panel { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 360px; + max-width: 88vw; + z-index: 10; + background: linear-gradient(180deg, #161320, #100e17); + border-left: 1px solid var(--border); + transform: translateX(100%); + transition: transform 0.38s cubic-bezier(0.2, 0.7, 0.2, 1); + display: flex; + flex-direction: column; + box-shadow: -30px 0 70px -30px rgba(0, 0, 0, 0.8); +} +.retro .panel.open { + transform: none; +} +.retro .panel-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 22px 14px; +} +.retro .panel-head h3 { + font-family: 'Fraunces', serif; + font-weight: 600; + font-size: 1.3rem; + margin: 0; +} +.retro .panel-head .x { + width: 34px; + height: 34px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--surface); + color: var(--muted); + cursor: pointer; + display: grid; + place-items: center; + font-size: 1.1rem; +} +.retro .panel-head .x:hover { + border-color: var(--brand); + color: var(--text); +} +.retro .panel-body { + overflow-y: auto; + padding: 6px 22px 26px; + flex: 1; +} +.retro .fgroup { + padding: 16px 0; + border-top: 1px solid var(--border); +} +.retro .fgroup:first-child { + border-top: none; +} +.retro .flabel { + font-family: 'JetBrains Mono', monospace; + font-size: 0.66rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--faint); + margin-bottom: 11px; +} +.retro .chips { + display: flex; + flex-wrap: wrap; + gap: 7px; +} +.retro .chip { + font-family: 'JetBrains Mono', monospace; + font-size: 0.74rem; + color: var(--muted); + cursor: pointer; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 999px; + padding: 6px 12px; + transition: 0.18s; + user-select: none; +} +.retro .chip:hover { + border-color: var(--brand-soft); + color: var(--text); +} +.retro .chip.on { + color: #fff; + background: rgba(120, 43, 241, 0.22); + border-color: rgba(120, 43, 241, 0.6); +} +.retro .frow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 0; +} +.retro .frow .t { + font-size: 0.92rem; + color: var(--text); +} +.retro .dates { + display: flex; + gap: 8px; +} +.retro .dates input { + flex: 1; + min-width: 0; + font-family: 'JetBrains Mono', monospace; + font-size: 0.82rem; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px 10px; + color-scheme: dark; +} +.retro .fsel { + width: 100%; + font-family: 'JetBrains Mono', monospace; + font-size: 0.82rem; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 9px 10px; + color-scheme: dark; +} +.retro .toggle { + width: 40px; + height: 23px; + border-radius: 999px; + background: var(--surface-2); + border: 1px solid var(--border); + position: relative; + cursor: pointer; + flex: none; + transition: 0.2s; +} +.retro .toggle::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 17px; + height: 17px; + border-radius: 50%; + background: var(--faint); + transition: 0.22s; +} +.retro .toggle.on { + background: rgba(120, 43, 241, 0.4); + border-color: var(--brand); +} +.retro .toggle.on::after { + left: 19px; + background: var(--brand-soft); +} + +@media (max-width: 880px) { + .retro .stats { + grid-template-columns: repeat(3, 1fr); + } +} +@media (max-width: 680px) { + .retro .slide-inner { + padding: 70px 18px 116px; + } + .retro .counter .slabel { + display: none; + } + .retro .dots { + max-width: 42%; + } +} +@media (max-width: 520px) { + .retro .stats { + grid-template-columns: repeat(2, 1fr); + } +} +@media (prefers-reduced-motion: reduce) { + .retro *, + .retro *::before, + .retro *::after { + animation: none !important; + transition: none !important; + } +} diff --git a/vite.config.js b/vite.config.js index b9201b1c7..fddf3d908 100644 --- a/vite.config.js +++ b/vite.config.js @@ -13,6 +13,7 @@ export default defineConfig({ 'app-modules/he4rt/resources/css/theme.css', 'app-modules/he4rt/resources/css/themes/3pontos/theme.css', 'app-modules/docs/resources/css/theme.css', + 'app-modules/portal/resources/css/retrospective.css', ], refresh: true, }), From 17ef31846e7d082dca5aa571e1a75ef8e9aecbc6 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Fri, 5 Jun 2026 23:32:15 -0300 Subject: [PATCH 11/27] feat(portal): layout deck sem navbar/footer --- .../views/components/layouts/deck.blade.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 app-modules/portal/resources/views/components/layouts/deck.blade.php diff --git a/app-modules/portal/resources/views/components/layouts/deck.blade.php b/app-modules/portal/resources/views/components/layouts/deck.blade.php new file mode 100644 index 000000000..25620d299 --- /dev/null +++ b/app-modules/portal/resources/views/components/layouts/deck.blade.php @@ -0,0 +1,16 @@ +@props (['title' => 'Quem fez a He4rt bater']) + + + + + + + {{ $title }} - {{ config('app.name') }} + @vite (['app-modules/portal/resources/css/retrospective.css']) + @fluxAppearance + + + {{ $slot }} + @fluxScripts + + From a864e5e6e57f5bf347ee8152d3f5625fb003866b Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Fri, 5 Jun 2026 23:32:20 -0300 Subject: [PATCH 12/27] =?UTF-8?q?feat(portal):=20componentes=20reutiliz?= =?UTF-8?q?=C3=A1veis=20do=20deck=20(badges,=20pr-row,=20chips,=20person-c?= =?UTF-8?q?ard)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/retro/activity-chips.blade.php | 61 +++++++++++++++++++ .../views/components/retro/badges.blade.php | 15 +++++ .../components/retro/person-card.blade.php | 32 ++++++++++ .../views/components/retro/pr-row.blade.php | 17 ++++++ 4 files changed, 125 insertions(+) create mode 100644 app-modules/portal/resources/views/components/retro/activity-chips.blade.php create mode 100644 app-modules/portal/resources/views/components/retro/badges.blade.php create mode 100644 app-modules/portal/resources/views/components/retro/person-card.blade.php create mode 100644 app-modules/portal/resources/views/components/retro/pr-row.blade.php diff --git a/app-modules/portal/resources/views/components/retro/activity-chips.blade.php b/app-modules/portal/resources/views/components/retro/activity-chips.blade.php new file mode 100644 index 000000000..1c14db3da --- /dev/null +++ b/app-modules/portal/resources/views/components/retro/activity-chips.blade.php @@ -0,0 +1,61 @@ +@props (['person']) +@php ($stateColor = fn($state) => [ 'merged' => 'var(--st-merged)', 'open' => 'var(--st-open)', 'closed' => 'var(--st-closed)' ][$state ?? ''] ?? 'var(--st-open)') +
+ @if (count($person['pr_refs'])) +
+ Abriu PR + + @foreach ($person['pr_refs'] as $ref) + + #{{ $ref['num'] }}{{ $ref['title'] }} + @endforeach + +
+ @endif + @if ($person['reviews'] > 0) +
+ Revisou{{ $person['reviews'] }} reviews +
+ @endif + @if (count($person['issue_refs'])) +
+ Abriu issue + + @foreach ($person['issue_refs'] as $ref) + + #{{ $ref['num'] }}{{ $ref['title'] }} + @endforeach + +
+ @endif + @if ($person['comments'] > 0) +
+ Comentou{{ $person['comments'] }} comentários +
+ @endif + @if ($person['commits'] > 0) +
+ Commitou{{ $person['commits'] }} commits +
+ @endif +
diff --git a/app-modules/portal/resources/views/components/retro/badges.blade.php b/app-modules/portal/resources/views/components/retro/badges.blade.php new file mode 100644 index 000000000..b10cd56f3 --- /dev/null +++ b/app-modules/portal/resources/views/components/retro/badges.blade.php @@ -0,0 +1,15 @@ +@props (['additions' => 0, 'deletions' => 0, 'files' => null]) +@php ($total = max(1, $additions + $deletions)) + +@if ($additions > 0) + +{{ number_format($additions, 0, ',', '.') }} +@endif +@if ($deletions > 0) + −{{ number_format($deletions, 0, ',', '.') }} +@endif +@if (!is_null($files)) + {{ $files }} arq. +@endif diff --git a/app-modules/portal/resources/views/components/retro/person-card.blade.php b/app-modules/portal/resources/views/components/retro/person-card.blade.php new file mode 100644 index 000000000..7ecb5e6b0 --- /dev/null +++ b/app-modules/portal/resources/views/components/retro/person-card.blade.php @@ -0,0 +1,32 @@ +@props (['person', 'rank' => null, 'size' => 44]) +@php ($ns = $size >= 80 ? '1.7rem' : '1.18rem') +
+
+ + {{ $person['login'] }} + +
+ {{ '@' . $person['login'] }} +
{{ $person['total'] }} {{ $person['total'] === 1 ? 'interação' : 'interações' }}
+
+ @if ($rank) + #{{ $rank }} + @endif +
+ +
diff --git a/app-modules/portal/resources/views/components/retro/pr-row.blade.php b/app-modules/portal/resources/views/components/retro/pr-row.blade.php new file mode 100644 index 000000000..c46f1c4bc --- /dev/null +++ b/app-modules/portal/resources/views/components/retro/pr-row.blade.php @@ -0,0 +1,17 @@ +@props (['pr']) +@php ($stateColor = ['merged' => 'var(--st-merged)', 'open' => 'var(--st-open)', 'closed' => 'var(--st-closed)'][ $pr['state'] ?? '' ] ?? 'var(--st-open)') + + + #{{ $pr['num'] }} + + {{ + $pr['title'] !== '' + ? $pr['title'] + : 'PR #' . $pr['num'] + }} + + {{ '@' . $pr['author_login'] }} + + + + From d082842790642b46a4fb5bdc56f87de58f87931d Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Fri, 5 Jun 2026 23:32:24 -0300 Subject: [PATCH 13/27] feat(portal): componentes de slide do deck da retrospectiva --- .../components/retro/slides/closing.blade.php | 34 +++++++++++ .../retro/slides/community.blade.php | 21 +++++++ .../components/retro/slides/core.blade.php | 19 +++++++ .../components/retro/slides/cover.blade.php | 23 ++++++++ .../retro/slides/highlights.blade.php | 56 +++++++++++++++++++ .../retro/slides/panorama.blade.php | 52 +++++++++++++++++ .../components/retro/slides/repo.blade.php | 54 ++++++++++++++++++ 7 files changed, 259 insertions(+) create mode 100644 app-modules/portal/resources/views/components/retro/slides/closing.blade.php create mode 100644 app-modules/portal/resources/views/components/retro/slides/community.blade.php create mode 100644 app-modules/portal/resources/views/components/retro/slides/core.blade.php create mode 100644 app-modules/portal/resources/views/components/retro/slides/cover.blade.php create mode 100644 app-modules/portal/resources/views/components/retro/slides/highlights.blade.php create mode 100644 app-modules/portal/resources/views/components/retro/slides/panorama.blade.php create mode 100644 app-modules/portal/resources/views/components/retro/slides/repo.blade.php 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 new file mode 100644 index 000000000..89b4fe0b1 --- /dev/null +++ b/app-modules/portal/resources/views/components/retro/slides/closing.blade.php @@ -0,0 +1,34 @@ +@props (['meta', 'people', 'period']) +
+
+ + + +

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. +

+
+ @foreach ($people as $p) + {{ $p['login'] }} + @endforeach +
+

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

+
+
diff --git a/app-modules/portal/resources/views/components/retro/slides/community.blade.php b/app-modules/portal/resources/views/components/retro/slides/community.blade.php new file mode 100644 index 000000000..3669fb349 --- /dev/null +++ b/app-modules/portal/resources/views/components/retro/slides/community.blade.php @@ -0,0 +1,21 @@ +@props (['people']) +@php ($tail = array_slice($people, 5)) +
+
+ A cauda que sustenta +

E toda a comunidade

+

Mais {{ count($tail) }} {{ count($tail) === 1 ? 'pessoa entrou' : 'pessoas entraram' }} com reviews, issues, comentários e commits. Cada toque conta.

+
+ @foreach ($tail as $person) +
+ @endforeach +
+
+
diff --git a/app-modules/portal/resources/views/components/retro/slides/core.blade.php b/app-modules/portal/resources/views/components/retro/slides/core.blade.php new file mode 100644 index 000000000..b4ab8235f --- /dev/null +++ b/app-modules/portal/resources/views/components/retro/slides/core.blade.php @@ -0,0 +1,19 @@ +@props (['people']) +@php ($top = array_slice($people, 0, 5)) +
+
+ O núcleo +

Quem puxou a frente

+

As pessoas que concentraram o grosso da entrega — abrindo PRs, revisando código umas das outras e mantendo as discussões vivas.

+ @if (count($top)) +
+ +
+
+ @foreach (array_slice($top, 1) as $i => $person) +
+ @endforeach +
+ @endif +
+
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 new file mode 100644 index 000000000..54f43120d --- /dev/null +++ b/app-modules/portal/resources/views/components/retro/slides/cover.blade.php @@ -0,0 +1,23 @@ +@props (['meta', 'period']) +
+
+ + + +
+ RETROSPECTIVA · {{ $period['since'] }} — {{ $period['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.

+
navegue com
+
+
diff --git a/app-modules/portal/resources/views/components/retro/slides/highlights.blade.php b/app-modules/portal/resources/views/components/retro/slides/highlights.blade.php new file mode 100644 index 000000000..d80ed214e --- /dev/null +++ b/app-modules/portal/resources/views/components/retro/slides/highlights.blade.php @@ -0,0 +1,56 @@ +@props (['highlights']) +@php ($stateColor = fn($state) => [ 'merged' => 'var(--st-merged)', 'open' => 'var(--st-open)', 'closed' => 'var(--st-closed)' ][$state ?? ''] ?? 'var(--st-open)') +
+
+ Os maiores +

Destaques do período

+

Os PRs de maior impacto no código — onde mais linhas mudaram. Metadados direto da GitHub API.

+ +
+
diff --git a/app-modules/portal/resources/views/components/retro/slides/panorama.blade.php b/app-modules/portal/resources/views/components/retro/slides/panorama.blade.php new file mode 100644 index 000000000..b6cc6eed9 --- /dev/null +++ b/app-modules/portal/resources/views/components/retro/slides/panorama.blade.php @@ -0,0 +1,52 @@ +@props (['meta']) +
+
+ O panorama +

O que a comunidade entregou

+

+ {{ $meta['people'] }} pessoas somaram + {{ $meta['total'] }} interações + em {{ number_format($meta['changed_files'], 0, ',', '.') }} arquivos tocados. +

+
+
+
{{ $meta['people'] }}
+
Pessoas
+
+
+
{{ $meta['prs'] }}
+
Pull Requests
+
+
+
{{ $meta['reviews'] }}
+
Reviews
+
+
+
{{ $meta['issues'] }}
+
Issues
+
+
+
{{ $meta['comments'] }}
+
Comentários
+
+
+
{{ $meta['commits'] }}
+
Commits
+
+
+
+ +{{ number_format($meta['additions'], 0, ',', '.') }} linhas + −{{ number_format($meta['deletions'], 0, ',', '.') }} linhas + {{ number_format($meta['changed_files'], 0, ',', '.') }} arquivos + {{ $meta['prs_merged'] }} merged · {{ $meta['prs_unmerged'] }} fechados +
+
+
diff --git a/app-modules/portal/resources/views/components/retro/slides/repo.blade.php b/app-modules/portal/resources/views/components/retro/slides/repo.blade.php new file mode 100644 index 000000000..305543d87 --- /dev/null +++ b/app-modules/portal/resources/views/components/retro/slides/repo.blade.php @@ -0,0 +1,54 @@ +@props (['repo', 'index' => 1]) +
+
+
+ Repositório {{ $index }} + + @foreach (array_slice($repo['people'], 0, 6) as $p) + {{ $p['login'] }} + @endforeach + +
+
+
+ + + + +
+
+

{{ $repo['name'] }}

+
{{ $repo['full_name'] }}
+
+
+
+ {{ $repo['metrics']['prs'] }} PRs + {{ number_format($repo['metrics']['changed_files'], 0, ',', '.') }} arquivos + @if ($repo['metrics']['additions'] > 0) + +{{ number_format($repo['metrics']['additions'], 0, ',', '.') }} + @endif + @if ($repo['metrics']['deletions'] > 0) + −{{ number_format($repo['metrics']['deletions'], 0, ',', '.') }} + @endif +
+
+ @forelse ($repo['prs'] as $pr) +
+ @empty +

Sem PRs nesse recorte — a atividade veio de reviews, issues e comentários.

+ @endforelse +
+
+
From 2ecd4375cec5264285e650955d1dbf8f6b0fc197 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Fri, 5 Jun 2026 23:32:29 -0300 Subject: [PATCH 14/27] feat(portal): casca Alpine do deck e painel de filtros --- .../views/components/retro/deck.blade.php | 81 +++++++++++++++ .../views/components/retro/filters.blade.php | 99 +++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 app-modules/portal/resources/views/components/retro/deck.blade.php create mode 100644 app-modules/portal/resources/views/components/retro/filters.blade.php diff --git a/app-modules/portal/resources/views/components/retro/deck.blade.php b/app-modules/portal/resources/views/components/retro/deck.blade.php new file mode 100644 index 000000000..c30c5946f --- /dev/null +++ b/app-modules/portal/resources/views/components/retro/deck.blade.php @@ -0,0 +1,81 @@ +@props (['stateKey' => '']) +
+
+ + + + + + + + {{ $filters ?? '' }} + +
+
+
+
+ + + +
{{ $slot }}
+ + +
+
diff --git a/app-modules/portal/resources/views/components/retro/filters.blade.php b/app-modules/portal/resources/views/components/retro/filters.blade.php new file mode 100644 index 000000000..b3ae69b46 --- /dev/null +++ b/app-modules/portal/resources/views/components/retro/filters.blade.php @@ -0,0 +1,99 @@ +@props ([ + 'repoOptions' => [], + 'repos' => [], + 'types' => [], + 'hideBots' => true, + 'byRepo' => true, + 'showHighlights' => true +]) +
+
+ +
From 82c0873cdafa2a3b8d0f295ee36bbc05d499bdb1 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Fri, 5 Jun 2026 23:32:34 -0300 Subject: [PATCH 15/27] =?UTF-8?q?feat(portal):=20retrospectiva=20vira=20de?= =?UTF-8?q?ck=20din=C3=A2mico=20filtr=C3=A1vel=20sem=20chrome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/community-retrospective.blade.php | 127 +++++------------- .../Livewire/CommunityRetrospectivePage.php | 106 ++++++++++++++- .../CommunityRetrospectivePageTest.php | 27 +++- 3 files changed, 156 insertions(+), 104 deletions(-) diff --git a/app-modules/portal/resources/views/community-retrospective.blade.php b/app-modules/portal/resources/views/community-retrospective.blade.php index 58afe7d31..09b31debb 100644 --- a/app-modules/portal/resources/views/community-retrospective.blade.php +++ b/app-modules/portal/resources/views/community-retrospective.blade.php @@ -1,99 +1,34 @@ -
-
-
-

Quem fez a He4rt bater

-

Participação da comunidade nos repositórios públicos.

+ + + + -
- - - {{ $data['period']['since'] }} → {{ $data['period']['until'] }} -
-
+ + - @php - $cards = [ - ['label' => 'Pessoas', 'value' => $data['meta']['people'], 'hint' => null], - [ - 'label' => 'PRs', - 'value' => $data['meta']['prs'], - 'hint' => $data['meta']['prs_merged'] . ' merged · ' . $data['meta']['prs_unmerged'] . ' fechados', - ], - ['label' => 'Reviews', 'value' => $data['meta']['reviews'], 'hint' => null], - ['label' => 'Issues', 'value' => $data['meta']['issues'], 'hint' => null], - ['label' => 'Comentários', 'value' => $data['meta']['comments'], 'hint' => null], - ['label' => 'Commits', 'value' => $data['meta']['commits'], 'hint' => null], - ]; - @endphp -
- @foreach ($cards as $card) -
-
{{ $card['value'] }}
-
{{ $card['label'] }}
- @if ($card['hint']) -
{{ $card['hint'] }}
- @endif -
- @endforeach -
+ @if ($byRepo) + @foreach ($data['repos'] as $i => $repo) + + @endforeach + @endif -
- @forelse ($data['people'] as $person) - - {{ $person['login'] }} -
-
{{ '@' . $person['login'] }}
-
- - {{ $person['prs'] }} PRs - @if ($person['prs_unmerged'] > 0) - · {{ $person['prs_unmerged'] }} não mergeado{{ $person['prs_unmerged'] > 1 ? 's' : '' }} - @endif - - {{ $person['reviews'] }} reviews - {{ $person['issues'] }} issues - {{ $person['comments'] }} comentários - {{ $person['commits'] }} commits -
-
-
-
{{ $person['total'] }}
-
interações
-
-
- @empty -

Ninguém bateu a He4rt nessa janela.

- @endforelse -
-
-
+ @if ($showHighlights && count($data['highlights'])) + + @endif + + @if (count($data['people'])) + + @if (count($data['people']) > 5) + + @endif + @endif + + + diff --git a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php index d072b0074..017adfe37 100644 --- a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php +++ b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php @@ -7,6 +7,7 @@ use Carbon\CarbonImmutable; use Carbon\CarbonInterface; use He4rt\Identity\Tenant\Models\Tenant; +use He4rt\IntegrationGithub\Models\GithubContribution; use He4rt\Portal\Retrospective\CommunityRetrospective; use He4rt\Portal\Retrospective\RetrospectiveFilters; use Illuminate\Contracts\View\View; @@ -15,13 +16,13 @@ use Livewire\Attributes\Url; use Livewire\Component; -#[Layout('portal::components.layouts.app')] +#[Layout('portal::components.layouts.deck')] #[Title('Quem fez a He4rt bater')] final class CommunityRetrospectivePage extends Component { - public string $tenantId; + private const array ALL_TYPES = ['pr', 'review', 'issue', 'comment', 'commit']; - public string $tenantName; + public string $tenantId; #[Url] public ?string $since = null; @@ -29,6 +30,32 @@ 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 mount(?string $tenantSlug = null): void { $slug = $tenantSlug ?? config()->string('he4rt.main_tenant'); @@ -37,7 +64,27 @@ public function mount(?string $tenantSlug = null): void $tenant = Tenant::query()->where('slug', $slug)->firstOrFail(); $this->tenantId = $tenant->id; - $this->tenantName = $tenant->name; + } + + public function toggleType(string $type): void + { + $this->types = $this->toggleAware($this->types, self::ALL_TYPES, $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' => null, + default => CarbonImmutable::now()->startOfWeek(CarbonInterface::MONDAY)->subWeek()->toDateString(), + }; + + $this->until = $preset === 'tudo' ? null : CarbonImmutable::now()->toDateString(); } public function render(): View @@ -50,10 +97,55 @@ 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($this->tenantId, $filters)->build(); + + $repoOptions = collect($this->allRepos()) + ->mapWithKeys(fn (string $repo): array => [$repo => (string) str($repo)->afterLast('/')]) + ->all(); + return view('portal::community-retrospective', [ - 'data' => new CommunityRetrospective($this->tenantId, RetrospectiveFilters::period($since, $until))->build(), - 'sinceValue' => $since->toDateString(), - 'untilValue' => $until->toDateString(), + '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, + ])), ]); } + + /** + * 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, true) + ? array_values(array_diff($current, [$value])) + : array_values(array_unique([...$current, $value])); + + return count($next) === count($all) ? [] : $next; + } + + /** + * @return list + */ + private function allRepos(): array + { + /** @var list $repos */ + $repos = GithubContribution::query() + ->where('tenant_id', $this->tenantId) + ->distinct() + ->orderBy('repo') + ->pluck('repo') + ->all(); + + return $repos; + } } diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php index f2c366bca..d51fd83de 100644 --- a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php +++ b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php @@ -56,5 +56,30 @@ livewire(CommunityRetrospectivePage::class, ['since' => '2026-06-01', 'until' => '2026-06-07']) ->assertOk() ->assertSee('rejeitada') - ->assertSee('não mergeado'); + ->assertSee('fechados'); +}); + +it('não renderiza o chrome do portal (sem navbar)', function (): void { + test()->get('/comunidade/retrospectiva') + ->assertOk() + ->assertDontSee('Área do Usuário') + ->assertSee('Quem fez a He4rt'); +}); + +it('filtra por tipo ao alternar um tipo de contribuição', function (): void { + GithubContribution::factory()->for($this->tenant)->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 { + livewire(CommunityRetrospectivePage::class) + ->set('hideBots', false) + ->assertSet('hideBots', false); }); From 77e1a2c4216e9e8242e138a4a48881d371d4775e Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Sat, 6 Jun 2026 08:58:44 -0300 Subject: [PATCH 16/27] =?UTF-8?q?feat(portal):=20refina=20painel=20de=20fi?= =?UTF-8?q?ltros=20(tipografia=20menor,=20=C3=ADcones,=20chips=20coloridos?= =?UTF-8?q?,=20descri=C3=A7=C3=B5es)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../portal/resources/css/retrospective.css | 55 +++++++--- .../views/components/retro/filters.blade.php | 100 ++++++++++++++---- 2 files changed, 123 insertions(+), 32 deletions(-) diff --git a/app-modules/portal/resources/css/retrospective.css b/app-modules/portal/resources/css/retrospective.css index 531e7dbef..a7f6f4470 100644 --- a/app-modules/portal/resources/css/retrospective.css +++ b/app-modules/portal/resources/css/retrospective.css @@ -697,7 +697,7 @@ .retro .panel-head h3 { font-family: 'Fraunces', serif; font-weight: 600; - font-size: 1.3rem; + font-size: 1.05rem; margin: 0; } .retro .panel-head .x { @@ -722,7 +722,7 @@ flex: 1; } .retro .fgroup { - padding: 16px 0; + padding: 14px 0; border-top: 1px solid var(--border); } .retro .fgroup:first-child { @@ -730,49 +730,78 @@ } .retro .flabel { font-family: 'JetBrains Mono', monospace; - font-size: 0.66rem; + font-size: 0.62rem; letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); - margin-bottom: 11px; + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 7px; +} +.retro .flabel svg { + width: 13px; + height: 13px; + flex: none; + stroke: var(--brand-soft); + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; } .retro .chips { display: flex; flex-wrap: wrap; - gap: 7px; + gap: 6px; } .retro .chip { font-family: 'JetBrains Mono', monospace; - font-size: 0.74rem; + font-size: 0.68rem; color: var(--muted); cursor: pointer; background: var(--surface); border: 1px solid var(--border); border-radius: 999px; - padding: 6px 12px; + padding: 5px 11px; transition: 0.18s; user-select: none; + display: inline-flex; + align-items: center; } .retro .chip:hover { border-color: var(--brand-soft); color: var(--text); } +.retro .chip .d { + width: 7px; + height: 7px; + border-radius: 50%; + display: inline-block; + margin-right: 6px; + background: var(--ck, var(--brand-soft)); +} .retro .chip.on { color: #fff; - background: rgba(120, 43, 241, 0.22); - border-color: rgba(120, 43, 241, 0.6); + background: color-mix(in srgb, var(--ck, var(--brand)) 22%, transparent); + border-color: color-mix(in srgb, var(--ck, var(--brand)) 55%, transparent); } .retro .frow { display: flex; align-items: center; justify-content: space-between; gap: 10px; - padding: 8px 0; + padding: 7px 0; } .retro .frow .t { - font-size: 0.92rem; + font-size: 0.82rem; color: var(--text); } +.retro .frow .s { + font-size: 0.68rem; + color: var(--faint); + margin-top: 2px; + line-height: 1.4; +} .retro .dates { display: flex; gap: 8px; @@ -781,7 +810,7 @@ flex: 1; min-width: 0; font-family: 'JetBrains Mono', monospace; - font-size: 0.82rem; + font-size: 0.78rem; color: var(--text); background: var(--surface); border: 1px solid var(--border); @@ -792,7 +821,7 @@ .retro .fsel { width: 100%; font-family: 'JetBrains Mono', monospace; - font-size: 0.82rem; + font-size: 0.78rem; color: var(--text); background: var(--surface); border: 1px solid var(--border); diff --git a/app-modules/portal/resources/views/components/retro/filters.blade.php b/app-modules/portal/resources/views/components/retro/filters.blade.php index b3ae69b46..b28be108a 100644 --- a/app-modules/portal/resources/views/components/retro/filters.blade.php +++ b/app-modules/portal/resources/views/components/retro/filters.blade.php @@ -6,6 +6,15 @@ 'byRepo' => true, 'showHighlights' => true ]) +@php + $typeOptions = [ + 'pr' => ['PRs', '--t-pr'], + 'review' => ['Reviews', '--t-review'], + 'issue' => ['Issues', '--t-issue'], + 'comment' => ['Comentários', '--t-comment'], + 'commit' => ['Commits', '--t-commit'], + ]; +@endphp
-
Período
+
+ + + + + + + Período +
Última semana Último mês @@ -27,7 +44,13 @@
-
Repositórios
+
+ + + + + Repositórios +
@forelse ($repoOptions as $full => $name) {{ $name }} @empty - nenhum repo com dados ainda + nenhum repo com dados ainda @endforelse
-
Tipos
+
+ + + + + + + Tipos de contribuição +
- @foreach ([ - 'pr' => 'PRs', - 'review' => 'Reviews', - 'issue' => 'Issues', - 'comment' => 'Comentários', - 'commit' => 'Commits' - ] - as $key => $label) + @foreach ($typeOptions as $key => [$label, $color]) {{ $label }}{{ $label }} @endforeach
-
Desfecho de PR
+
+ + + + + Desfecho de PR +
-
Pessoa
+
+ + + + + Pessoa +
-
Ordenar ranking
+
+ + + + + + Ordenar ranking +
+
+ + + + + Exibição +
-
Ocultar bots
+
+
Ocultar bots
+
Remove contas automáticas (ex.: dependabot) do ranking.
+
-
Slides por repositório
+
+
Slides por repositório
+
Um slide para cada repositório, listando seus PRs.
+
-
Slide de destaques
+
+
Slide de maiores PRs
+
Um slide com os PRs de maior impacto no período (por linhas mudadas).
+
From 04d5a0245287365c039c0b6fd668847b507df90a Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Sat, 6 Jun 2026 09:27:06 -0300 Subject: [PATCH 17/27] =?UTF-8?q?feat(portal):=20troca=20cora=C3=A7=C3=A3o?= =?UTF-8?q?=20da=20capa=20por=20imagem=20e=20adiciona=20logo=20He4rt=20com?= =?UTF-8?q?=20LED=20ao=20fundo=20dos=20slides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../portal/resources/css/retrospective.css | 72 ++++++++++++++++++ .../views/components/retro/deck.blade.php | 23 ++++++ .../components/retro/slides/cover.blade.php | 18 ++--- public/images/retro-heart.png | Bin 0 -> 8729 bytes 4 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 public/images/retro-heart.png diff --git a/app-modules/portal/resources/css/retrospective.css b/app-modules/portal/resources/css/retrospective.css index a7f6f4470..1eca260c0 100644 --- a/app-modules/portal/resources/css/retrospective.css +++ b/app-modules/portal/resources/css/retrospective.css @@ -82,6 +82,50 @@ mix-blend-mode: overlay; } +/* logo He4rt ao fundo, com um LED percorrendo as linhas (efeito circuito) */ +.retro .logo-bg { + position: absolute; + inset: 0; + z-index: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + overflow: hidden; +} +.retro .logo-bg svg { + width: min(78vh, 64vw); + height: auto; + transform: translateY(-2%); +} +/* traço de base — circuito apagado */ +.retro .logo-bg .trace { + fill: none; + stroke: rgba(120, 43, 241, 0.13); + stroke-width: 1.6; +} +/* LED que percorre o caminho (pathLength normalizado para 100) */ +.retro .logo-bg .led { + fill: none; + stroke: var(--brand-soft); + stroke-width: 3.2; + stroke-linecap: round; + stroke-dasharray: 8 92; + stroke-dashoffset: 100; + filter: drop-shadow(0 0 5px var(--brand-2)) drop-shadow(0 0 13px rgba(120, 43, 241, 0.9)); + animation: retro-led 5.4s linear infinite; +} +.retro .logo-bg .led.b { + stroke: var(--brand-2); + animation-duration: 7.2s; + animation-delay: -3s; +} +@keyframes retro-led { + to { + stroke-dashoffset: 0; + } +} + /* progress bar */ .retro .progress { position: absolute; @@ -216,6 +260,34 @@ stroke-dashoffset: 0; } } +.retro .cover-heart { + display: block; + width: 86px; + height: 86px; + margin: 0 auto; + object-fit: contain; + filter: drop-shadow(0 0 22px rgba(120, 43, 241, 0.7)); + transform-origin: center 62%; +} +.retro .slide.active .cover-heart { + animation: retro-beat 2s 0.7s ease-in-out infinite; +} +@keyframes retro-beat { + 0%, + 36%, + 100% { + transform: scale(1); + } + 12% { + transform: scale(1.13); + } + 24% { + transform: scale(1.04); + } + 30% { + transform: scale(1.08); + } +} .retro .hint { font-family: 'JetBrains Mono', monospace; font-size: 0.78rem; 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 c30c5946f..0198f13e9 100644 --- a/app-modules/portal/resources/views/components/retro/deck.blade.php +++ b/app-modules/portal/resources/views/components/retro/deck.blade.php @@ -8,6 +8,29 @@ + + {{ $filters ?? '' }}
- - - +
+ He4rt +
RETROSPECTIVA · {{ $period['since'] }} — {{ $period['until'] }}
@@ -15,9 +11,11 @@ -

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 nos repositórios públicos, em gente, código e contexto. {{ $meta['people'] }} pessoas, {{ $meta['total'] }} interações, {{ number_format($meta['additions'], 0, ',', '.') }} linhas.

navegue com
diff --git a/public/images/retro-heart.png b/public/images/retro-heart.png new file mode 100644 index 0000000000000000000000000000000000000000..cdfca88f02f479b8e7a0ca4c4cdc27d2a55d1b41 GIT binary patch literal 8729 zcmV+!BIezRP)XmwIUnr3x(?q@N-MTD$_Zg%&7QtQ3bL!Gp)$ z)_uq4%K80q?wy_8Y#>OGet(C@ve}tCcV^Bxuk-fTTk!vn|9^}aQc3^;Xr&LA{2+w* zs6Xb1GK`P<1H6x+10f&=!~u(;1@%x1DS#X>0HguOfBgR+?>vP6ope!&5WB~W0{vz*Zd=da6fGE%iOa`U{6M!b59;gM%7y?M? zLInWn23mm@U>&d$SOsiEfyeu(zvRbd2q6$ch}pAem-qDaH23uMOvvSOlRVFxXsUkmeI|P<>qGqy>xxW&?Zpzo!5-D5;@7ybzeC zNjMzFG);6}M@m`z5kS6BAd|`9y2B1K2j~Yj0B-=#123Z%G4)Y@`HyHsQ3uejz&v0l z)In5*S`R?Yxn)^cmW5#$=(-XhrSqQ8=gDTXfvOga0_vbz1H1;j0K9@au<{@Em*3L( zH~~Zl#sm8ShXK2zq=W&GNF-=%Y-IA}$xN6qf#&9Bnwy(xY;2^mvXV$7f?*g)De*i{ z0YnH=sT3_OEo|Jlk&PQSvUcrS)~s2>rcImZ@9z(QBY_^^E#NWWQD713B>Z0g*yKXJ5t*s=J$pAPm(2fGh!@%Px@Yo;qq?C@213==yT;PkS zMH&xikw}C|lO{1|&K!2%eRp==d1od}m_T)PHI`+)XCu+JZ3YJi*|cdBOO`C*#TQ@X zrI%h}>C&b2_xA^v^`cI=J5h_*#wSE2*oq-glK4Q>obT=bO?i1a)22-;&iTxlGZ{B_ zER|JN?|IIploSdD`uh4Uj@tu#(mTi z_#Q?B@C%gM=n5g^gb5SmNhh5opM3I3IXF1@k!toPWMtv0{aE9OpxySI_gL<2W*(&&yOQC6mddT(V?| zJmQEW0&uhfpX1}9{(op=9Pm4oAuMZZYUGJ0o+#gV2fSZ*P}ZUU{XQHEWg> zLP(TH=0TKdxb1Y2_dWzL2W3O$06Asy6nWu=7s?GAHhkz3+Vec=x~?n~3NoEe%l`g; z+1=eOySlpME3dpF4?5_e|7XEr07s!b6*3$S%VUo{RzCaev(mQhk9yy~>#=_QdU@7a zXUT?!hG0QEfHP69(2vVV06#;;AY@flmHgSyekNC}Sn;6(!}C1pd7iXwTV^tuA}HG1 z+huE8n`~=umoG0|C=WdF!2f5#Q3d=TD&8Px%$Onn_P4*uR4VmR@ALOHT-TM)KmWWu z;)o-pVHgq>Q@I}%H~6T>G~hO%plO=ifB*gE=GaNB#&Qh7wFDrcG@2qiI_4 z-@$e7Wq6*)(@#Im>8GF03opC?&&jNo2w%dOD?fLVc|IC-a{AEI+(7U=(un?~6;<_%5<6zr%@uyHI;5ZJB z7Js{*i|2a9za7UZ-lz0_!E1u|8umrD5->stCQO*XK?fZ~S63HHmMn=#DfdMs zP`=2vPoanarvSeZLR1}f)KOe}?X}FCH}5^gF@m`W=B{+UOaGnE=ZkY_I}WyE56!9c zir^@{pQdTFwYGBBS!V^{Xy;=N4*1{zq5)q8t^&sFx8HtTcinaDx#yl+z1d{Ri4y=K z070;^i(qiQ%}zL^1pmDxa@ak$B!x=v={U}2K!MG{QhKjo6UAaN_St72JkR5eH{LL8 z+n$SxPCv)ClO!=zT;^2MG^0QG{tvk9^2-=E?%joHMjl4CZDSV-*dyjX_%~AGx++=P z^JH<}Y}*-i-ldf2x?Tc~$3TJOBtGWgfDcB+(om7v%Yf=*jyZ8SP{* z{n^3|)N>V~Sxz|N1n$58eo)fm zIFzgPgB~&9T;QkSaM=9iFMr9Yr=GgiYHaCYT{t zgw3SI$U{j8K}UN#XPtExk3L$I9G?hqzz37={srLoK=qft^d&C8{Bjx^8n)_%!O7(~ z4xZ~N^{C{87ce9h{7tAHD%sql0AuJ4lr#}SAP~h9&htE6$H6eoVXt*vw`j|h-s_$0 zAcR1A9=G0lD+?AZ@ZXt#qH>Eq=uremBoZ;tI_oS>KKbOWQe#I-=>X(KDLmr463nF# z=$eivJ#5=9&XMbSqaU#nkP=PTX>V)etoidlk>G&$50Jx9;HdfH7r)4rS6)e7UEMpr zs8q!X7OJ@EJkOtBe>(@pw-5?6B^!DqAU2aS0yLqdfG54;>!dF+)QiCLT!f|-Q{qQT zuF~w_VXrND-+$h63+JDIK7)gU61V|n8}xp_>-E0CQ99?PlCX4MgEl-`bJS=g~9`(=?0kscxwxho)&oRSPI?Aa&ZhAVAmk zq0LYN(0~5(pIk5mjvIlK*~Z|QKYu>I_{A^Y5getSr@(#-yi6m(Kk{{gA%K8Vxs()f zNTpL4hCw6}87_S!Xu2}U&ph)?9)J9C7y`$?_ymCi-n)@L4&|+yy~iGVaP76%GJgE{ zw|zm;&aj&P1yt@_C1JJ%l#%v_uIuQ!K1}Ka&jq%|$W7z<2B}2) zl1LWamBD-5`px<-3@ zJM)Jdley*Qo7sKt+_!x}sjV@pG5B^y_-G$m2=ci+x}guN zXd4NZQsa6{QfH(pRSFKz2S*TJkqlSoZoTzZE*uVyQ&9P4ALMw~;20@2hB-bwiImEo zE1iFV05M!m4J1QRnwHFydLc;Z6=y})b=un6n19w;Jo3mR@CgS8yf+)CR3H@B1$%Q82YDxAw|2yD-cMp(xz^{Z5)=4Lw#4*Plv&9SFaiK1G7S*HD*OV^u$P+#oVheW5mJD%k6lpf{q`mdFk2g3zRxv(A%4sR4t3|k*xa-2PX7&v~y{|Go>>j5$ibt)J?{J{^{ zYp=aVy{J@j1Str^B*!p~NRF!C14F4KzQpicSDkhx?@=mgim{0j`(-q5l`Kg~*^(NC zP@b4z1ow&c_S_c9ELP1;0;q;Jpiyu@U6ui_f-2Ij-Q6d-%AN7^EYoxwvpDoKP*&}QQnDBVGQ~STU za&-TRk4jh0++FLK1-yayJtP>(sJ3K375`_`C{Jtml6`WWrv!nX`%4J!DhlWCGZLf% zdLZ)K)yw2#oLAY!nYjHufp{bV)T1xx(f4t{ymZYaq|_0$bONjUO&yHSiJztSnC*6s z>G3n0`;#qvN<%Oex&81uEQDL4gss^hb?|8*wiGk)HhS*E3CBEZA8iAb(C%B#`_X)l z<*4F_=#hXD`rBai|G6^KfQP5O@pjgeMZq`dm(Vbm12)`!^iPVcuI|v+RuQMNg zkcq$wpuSpyhdr4!=%G@{6>W2J78 z`nt=VddU8TQC}szn5Q8-sHvcZCWs~D3zI@`OTZNqzuMSqwHznlZhwd-N0>$9UO;3K z5*{X(NvWl8j!4z=HcjTHmys4}FLDDrI3ZR08A(ojX#NXz9KVR-<1fC1ClY~p(pVFJ zBJKl_)>l|^T1&@;vyD)?%nz>gMafa=RX6HC;U;b7`|U!y=9$aa$|D(r9E1o!8>FI0xFts zG3@ze?0cd?_nk~*TON;r){M+dXGQMxmGxiKKT3465zIW#{l))`fRqc?`_5K|mDNvdy&S>JR62~z z@z77ZuRHwJ=jrQPh*GCkPtDKJToSIs(s3)+>EImtS>3(3hp6pwkueMr!or&E86e?O zzyupTVfc4WdNS+~cDz9Ci#k~DHv-Ws*$T0=<QY+(X+!n>HL%=6+PJiS!U#*lc!h0_L#AYvhN!a7@?&sF@32!qQV5fs7YtS88l(Yk z?>A9*yAI9j(ET*=FH&a}2eSNga(_Trx(Sla^gaUsK)qoJK7hozP*Fe^EdfoX;8cyn zPBV9~jDVGfG+Cv=cJ#7L7!XavCpOJ)4!<6~(b26-nBe+iXb)*zjAk5WNf);~v_beY zd*%v7b_M~eSe6!{GzSQ)$5{hEQ1}7!|5H(b=3VekCX8X{SPU(QMLAA4{ML6bM^Y;)Kgnz!;&&2QN1`$_n@#nm*`cl+68*Y7Ye zamDr=?>WRtUuMP-cOWVDk@_Xv)qwAgocFkYO@t>#z_Yu$iy7^ELMo@m2{k^i*4DQ> zPmx#9Ut}t9v7HO;56Q*Fsz5LD@67yr^8V$%dv2|d^bl$Ki5sZZ9zn3j+JBWy*;|6^ z-Du`B&|G&X_IPy9itlO!?(8T3Kg;w#2|X{F%f;2^j?y%t`T!d7ul+;Z2YU25d`nMn zd`f+1=hG7QMr(yAV>H$PZbO58fZww8M}P8jEB;tjwK3k~{s4z=58EvIqZnU2aeoR! zv{b!(a1gvlXf3yGfy^X|oUnEarT_ob)tiCwK&{Vo&X+b=cWZdODcAmnoCctN-SkIh zhws01YeAW$PcE!m9rSHWGU-%$SRh)JKCP@FXG=%9zB9bpBINtGr;o3(w_9m&O`&jN zkjpIA!AW-Q1**4=W_Y`$*WU*WieRX5;ud6g8 zC8_eJ4x(iRDqtX0R3CG-)LL7m){6!4K-A_8$GWKPJ|EYiB0tFvLJWK^SBp!vy=g5Csm$(!oi<*mR2tc*^a^2 z7dyFLfv5}Q&{Pmr6jD>~Kh4+oPv}kFTArRKYIXtxu>a%7W-nJ3!vmm}H=mJH;HOqN zKfnz`r_$LX;~!`Mgb*_6;8{e+2;L|-2zbD@$}j5Y@w%G7AjIO}`K0p!1i&PpB8D5G z6Pf;05_krVQMJ_mlD$uJ)}eo|rWdC3o0El29FJBr2F;(xRpkuL-~n(A40y-KK5-14 zk)1#|8a&^nKYAA3AG`WNAxcV#NW04u=4!Rnc{Si$?ysjQ2EZf&z!^00ZJ%?sRVEUn z6e^*ZX>4cKPDz?{+m{zTHEsTz{&_=t({Y9014|Y9h|&&V7^@goRI4H95|AH?|79(g z+$7(d|19>98v7LvL$fC0czt3YdIkQ1T`YN&UVag8dV-fmX6zqzy-qQdomW1>@X1nF zf!QM%qP(eO2SU5DYw}zR!X%h=A9R~%g&0!p8vLD=t_G95F(W^T@85hU**r9fAt11w zuX;B?6|~!MSx8_Q(7)iDM{^l(+Sia44RhF4e#0E|A2Hk8j|z|&2DLNdNi`P4Q`uC< z3G=;ezmVJ~ED_c{bBlQ#sjaJF!eDKS-J|h0yI}L08u*C-I0U=QqTplm?LRwC6EKXk zev^FHIb0Z5)flnJM}Es+Us~jAz^zaVhCem&=ICF)Q|ygp{>NLW1v9@N`2WW^_`V_b zi9RuWGrQ|0nn@eLb_L=ZX*lcTEk}#1-M*z;legv_unv=wArvQZgQWRv-=g!Xb(@P# zPG9{klwEu_{`{&vchewoCtnOLtQG!kXI_ywRyo)+8xG=h4WA>22>-7@jqUAX0G5{| zt-}r%8cEhSJ8CCPw}u)FKK12CCt`ivC}J;_*XQ<6-|IX%z?Xddvvm~zc!U)JA3u}t zkT(cmGmKUI$2?W>+UQ;}Lg7~hfh>(4#6W7ine1hW5f&`9^nna_T3>8;wr+$BM=yulS+%ywAw{ zH^rfR_LNDx0>HYH8O6R!s4(2GA=YcuY|*Gp{7A)qKOY*K?cS_qwm7ta=}EK&Q}CsZHn6W(`wt$xBbf}Ge@14`^imWXT~=(Y18pJ^ON|sHwfhhqYV^F z4nFxl!Ge9jZoqKQ{D))uOKQy){Ib~z!#YO5EhE1^4{RxRL?`Svg0sMik{(fctC$Ts1b(>a)um2SO;l$5q7XC8- z92fvU3jhGJRe7}$-kP`m<$oVNW&6@*M>FOCIMjP@vUHftXXH8RSq=lZH2eGg&*Dx! z(rOA-$`ACPvlYvIDQCTFvAk?SM(6LvjgILgpEUh@A5 z*jOD*WetV|q1;=Og==EA3>U*gC&%E=q*w11vl}Y0uz8L*j5jmPoI4tYr-vb+htaL* z!mtKJ`1jej=Fz-hZJ#h4@m1M(`gCUl4fNkl`CK>>cyO~-b!$%z1u)P#!|@YF`>TIn zPDoA#svL%vS#7qk8)^JO^v;RAZlcRi&S1`ht?!=+vcWcjQtdpVD= zy#819s)X^gR7#FV>Ld~0b*$seKTY=M=S+Eel>ZH3=MF@4I6t}ejyZ|2vv$k!Uo}gg z7NG5b^B+6;zKg+efM1b(7tj2Ycard0z%tKt!RdVP>W|D6OrYv^k55!0b3LK1S8_Kl zVjch=e}kWsr5q0JQu!pSg}w6~;OWR%Pp@XI-OIbx{aq)Ap;`Stuc>YELqIYI>qx}E zb|2&4gXAXdpYxNsMk86!9R)G&Srv|ByRVuNNI{}g_ZD8AXJux%xYXm!`J91>(0vA3 zsOq+1GRZGzKa^%~_$uv3|4ow-cX(d~XU^a`lT~&B^UwB%OX&|Ts1$Jf`HYv)N4PfX z@ZcC37j_?v`!A9RtrdJ1T(6vJ>IZd<`cj=qI$v@;{;2Hmx9aHW(;QJU(fWH$u?siV z!PkNA-1ZqkewFX`e&&FYJbTkc6PLH)BAdwX2k8X>4kUYLMn=43;zQc0pHm|e&0L;e zFG^Q;BmNtlLOGLDoBy?!Ch+7Hlos@?Kcz6=M9Et^_ZS=^AFuQ)po5G9@>BzW?=pi9 zl+9p=g%i-qbak2W=6<2C;{>&>mzxRLXGbR@nigy~WR}(z_oH*C6QfC3E3lZ$YN2R&ljxcx9quODUQ1!6OqcgJ4#}=Wv>grYUsBt8!0crryb3Qc_aoC66;n9f`TrQ&N zR1A(MX6NdI%*rNEHBgsyOuZ@B-E#{I4q&b9aojg@8)Q8T3}2w1QJm~RermXi*UqANqdZ`ix-;J z97_H1sOS6nA3vwkg*CmyB{o0@RAh9@MjD$(`_l_728or3>VWXOfT_sHGxs435|xE5 zn3(f<3UtqPUHVpiRz+n6h(3S{{0)-04|v%B_BsiQpfy5ZX=PoA=8~-=@x^kmV*pl` zrFI4T@nWn?1{@0O!fXdD@$O$HmNsMkV!#*#Kil)k?RiFLd_qV!9pVbXe0_#&U60}2 z`Fry`oW-gDXXelciM2NV&_G>S6G;y0y&l5fv6U^6`^SBPR64T3pNz0(nScf{oH15> z!M(Fjt??Zo3B-XDA#9F0u&Jt5s;!pQMgXC_-<1HXiUIU6P^?)YfE~bez&HgU3x^XQ z_($w|6u2!5ZCjGHJGmPbRJJ`Ze97;FbqTD3F!BOf8Ex;V;2NU=Dv7i2BL6#|&;1Mp zloBuwO0h4{dm;ploT&}Cr+d{l1KpDO0q}J;9hzn|oaF;1a$7>3eGpCyqE2^K)l>iN zXB+$fg;h95ey<6;f~gGG<2wgoLEe`$;o7w?L+dy|tp7MvN~k6go*XdH>Ggh|+ckMg z0R{ko%Y4ukbNrAJ1V#{Y5eE}hmm0qIuMA}%?_k^=fIi&m-2GzXzo&Hx#)hg?wWg#l zFy0vfplX6+^YF`N@q+Ps7O%1ezq!L|E#0Gk*=ZMkW_Vm(R4z(DQK9__*phSd^+RY7 z^4-aFYLzK_&7B}K!TNv|!F>qasxd{&#EfJak|%{y#zf zOGCit0sL=c_?rL(000C400aO)00;yC5CH@o0iOp91fc;h=8;(UJqGy(!UQfDdx&r& z@ Date: Sat, 6 Jun 2026 09:31:34 -0300 Subject: [PATCH 18/27] =?UTF-8?q?fix(portal):=20remove=20cora=C3=A7=C3=A3o?= =?UTF-8?q?=20do=20slide=20final=20e=20corrige=20corte=20do=20logo=20de=20?= =?UTF-8?q?fundo=20(viewBox)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/components/retro/deck.blade.php | 2 +- .../components/retro/slides/closing.blade.php | 17 ++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) 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 0198f13e9..16e0a90ee 100644 --- a/app-modules/portal/resources/views/components/retro/deck.blade.php +++ b/app-modules/portal/resources/views/components/retro/deck.blade.php @@ -9,7 +9,7 @@ From d85c602d20b298b11ce2add5494dde485ed55914 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Sat, 6 Jun 2026 10:24:43 -0300 Subject: [PATCH 19/27] =?UTF-8?q?feat(portal):=20anima=20a=20linha=20do=20?= =?UTF-8?q?batimento=20(ECG)=20com=20pulso=20cont=C3=ADnuo=20na=20capa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../portal/resources/css/retrospective.css | 35 +++++++++++++++++-- .../components/retro/slides/cover.blade.php | 10 +++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/app-modules/portal/resources/css/retrospective.css b/app-modules/portal/resources/css/retrospective.css index 1eca260c0..0d9b5a207 100644 --- a/app-modules/portal/resources/css/retrospective.css +++ b/app-modules/portal/resources/css/retrospective.css @@ -244,22 +244,44 @@ } .retro .ecg path { fill: none; - stroke: var(--brand); - stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; +} +/* linha de base — desenha na entrada e permanece visível */ +.retro .ecg .trace { + stroke: var(--brand); + stroke-width: 2.4; filter: drop-shadow(0 0 7px rgba(120, 43, 241, 0.85)); stroke-dasharray: 1500; stroke-dashoffset: 1500; } -.retro .slide.active .ecg path { +.retro .slide.active .ecg .trace { animation: retro-draw 1.8s 0.5s ease-out forwards; } +/* pulso que percorre o traçado para sempre (estilo monitor cardíaco) */ +.retro .ecg .pulse { + stroke: #ffffff; + stroke-width: 3; + filter: drop-shadow(0 0 4px var(--brand-2)) drop-shadow(0 0 11px rgba(120, 43, 241, 0.95)); + stroke-dasharray: 10 90; + stroke-dashoffset: 100; +} +.retro .slide.active .ecg .pulse { + animation: retro-ecg-sweep 2.4s 0.6s linear infinite; +} @keyframes retro-draw { to { stroke-dashoffset: 0; } } +@keyframes retro-ecg-sweep { + from { + stroke-dashoffset: 100; + } + to { + stroke-dashoffset: 0; + } +} .retro .cover-heart { display: block; width: 86px; @@ -960,4 +982,11 @@ animation: none !important; transition: none !important; } + /* sem animação, a linha do batimento precisa aparecer inteira (não em dashoffset) */ + .retro .ecg .trace { + stroke-dashoffset: 0; + } + .retro .ecg .pulse { + display: none; + } } 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..127043b2f 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 @@ -9,7 +9,15 @@

Quem fez a He4rt bater

Date: Sat, 6 Jun 2026 10:30:00 -0300 Subject: [PATCH 20/27] =?UTF-8?q?feat(portal):=20sincroniza=20batida=20do?= =?UTF-8?q?=20cora=C3=A7=C3=A3o=20com=20o=20pulso=20do=20ECG=20na=20capa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../portal/resources/css/retrospective.css | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/app-modules/portal/resources/css/retrospective.css b/app-modules/portal/resources/css/retrospective.css index 0d9b5a207..dd2a7e824 100644 --- a/app-modules/portal/resources/css/retrospective.css +++ b/app-modules/portal/resources/css/retrospective.css @@ -291,23 +291,33 @@ filter: drop-shadow(0 0 22px rgba(120, 43, 241, 0.7)); transform-origin: center 62%; } +/* a duração/delay batem com o pulso do ECG (.ecg .pulse) → ficam em fase. + os thumps em 40% e 69% coincidem com o comet cruzando os dois picos do traçado */ .retro .slide.active .cover-heart { - animation: retro-beat 2s 0.7s ease-in-out infinite; + animation: retro-beat 2.4s 0.6s ease-in-out infinite; } @keyframes retro-beat { 0%, - 36%, - 100% { + 32% { transform: scale(1); } - 12% { + 40% { transform: scale(1.13); } - 24% { - transform: scale(1.04); + 48% { + transform: scale(1); + } + 61% { + transform: scale(1); + } + 69% { + transform: scale(1.09); } - 30% { - transform: scale(1.08); + 77% { + transform: scale(1); + } + 100% { + transform: scale(1); } } .retro .hint { From dfaf419acf7c7f873402208fd95b04db0c0175c5 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Sat, 6 Jun 2026 10:35:47 -0300 Subject: [PATCH 21/27] =?UTF-8?q?revert(portal):=20volta=20a=20linha=20do?= =?UTF-8?q?=20ECG=20a=20animar=20s=C3=B3=20na=20entrada=20do=20slide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../portal/resources/css/retrospective.css | 61 ++++--------------- .../components/retro/slides/cover.blade.php | 10 +-- 2 files changed, 12 insertions(+), 59 deletions(-) diff --git a/app-modules/portal/resources/css/retrospective.css b/app-modules/portal/resources/css/retrospective.css index dd2a7e824..1eca260c0 100644 --- a/app-modules/portal/resources/css/retrospective.css +++ b/app-modules/portal/resources/css/retrospective.css @@ -244,44 +244,22 @@ } .retro .ecg path { fill: none; - stroke-linecap: round; - stroke-linejoin: round; -} -/* linha de base — desenha na entrada e permanece visível */ -.retro .ecg .trace { stroke: var(--brand); stroke-width: 2.4; + stroke-linecap: round; + stroke-linejoin: round; filter: drop-shadow(0 0 7px rgba(120, 43, 241, 0.85)); stroke-dasharray: 1500; stroke-dashoffset: 1500; } -.retro .slide.active .ecg .trace { +.retro .slide.active .ecg path { animation: retro-draw 1.8s 0.5s ease-out forwards; } -/* pulso que percorre o traçado para sempre (estilo monitor cardíaco) */ -.retro .ecg .pulse { - stroke: #ffffff; - stroke-width: 3; - filter: drop-shadow(0 0 4px var(--brand-2)) drop-shadow(0 0 11px rgba(120, 43, 241, 0.95)); - stroke-dasharray: 10 90; - stroke-dashoffset: 100; -} -.retro .slide.active .ecg .pulse { - animation: retro-ecg-sweep 2.4s 0.6s linear infinite; -} @keyframes retro-draw { to { stroke-dashoffset: 0; } } -@keyframes retro-ecg-sweep { - from { - stroke-dashoffset: 100; - } - to { - stroke-dashoffset: 0; - } -} .retro .cover-heart { display: block; width: 86px; @@ -291,33 +269,23 @@ filter: drop-shadow(0 0 22px rgba(120, 43, 241, 0.7)); transform-origin: center 62%; } -/* a duração/delay batem com o pulso do ECG (.ecg .pulse) → ficam em fase. - os thumps em 40% e 69% coincidem com o comet cruzando os dois picos do traçado */ .retro .slide.active .cover-heart { - animation: retro-beat 2.4s 0.6s ease-in-out infinite; + animation: retro-beat 2s 0.7s ease-in-out infinite; } @keyframes retro-beat { 0%, - 32% { + 36%, + 100% { transform: scale(1); } - 40% { + 12% { transform: scale(1.13); } - 48% { - transform: scale(1); - } - 61% { - transform: scale(1); + 24% { + transform: scale(1.04); } - 69% { - transform: scale(1.09); - } - 77% { - transform: scale(1); - } - 100% { - transform: scale(1); + 30% { + transform: scale(1.08); } } .retro .hint { @@ -992,11 +960,4 @@ animation: none !important; transition: none !important; } - /* sem animação, a linha do batimento precisa aparecer inteira (não em dashoffset) */ - .retro .ecg .trace { - stroke-dashoffset: 0; - } - .retro .ecg .pulse { - display: none; - } } 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 127043b2f..f9ee8ea5d 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 @@ -9,15 +9,7 @@

Quem fez a He4rt bater

Date: Sat, 6 Jun 2026 10:41:56 -0300 Subject: [PATCH 22/27] =?UTF-8?q?fix(portal):=20mant=C3=A9m=20as=20bolinha?= =?UTF-8?q?s=20da=20navbar=20centradas=20independente=20do=20t=C3=ADtulo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../portal/resources/css/retrospective.css | 18 ++++++++++++------ .../views/components/retro/deck.blade.php | 14 ++++++++------ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/app-modules/portal/resources/css/retrospective.css b/app-modules/portal/resources/css/retrospective.css index 1eca260c0..8292da482 100644 --- a/app-modules/portal/resources/css/retrospective.css +++ b/app-modules/portal/resources/css/retrospective.css @@ -625,7 +625,8 @@ right: 0; bottom: 0; z-index: 6; - display: flex; + display: grid; + grid-template-columns: 1fr minmax(0, auto) 1fr; align-items: center; gap: 16px; padding: 13px 22px; @@ -634,6 +635,8 @@ border-top: 1px solid var(--border); } .retro .counter { + justify-self: start; + min-width: 0; font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; color: var(--muted); @@ -646,12 +649,12 @@ color: var(--text); } .retro .dots { + justify-self: center; display: flex; gap: 9px; - margin: 0 auto; flex-wrap: wrap; justify-content: center; - max-width: 60%; + max-width: 100%; } .retro .dot { width: 9px; @@ -671,6 +674,12 @@ transform: scale(1.3); box-shadow: 0 0 10px var(--brand); } +.retro .navactions { + justify-self: end; + display: flex; + align-items: center; + gap: 16px; +} .retro .navbtn { width: 42px; height: 42px; @@ -944,9 +953,6 @@ .retro .counter .slabel { display: none; } - .retro .dots { - max-width: 42%; - } } @media (max-width: 520px) { .retro .stats { 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 16e0a90ee..3b1a4f0bf 100644 --- a/app-modules/portal/resources/views/components/retro/deck.blade.php +++ b/app-modules/portal/resources/views/components/retro/deck.blade.php @@ -93,12 +93,14 @@ class="deck-shell" - - +

From 6254d546f6594c4eea41c80941e8a977f6036eb7 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Sat, 6 Jun 2026 10:50:36 -0300 Subject: [PATCH 23/27] =?UTF-8?q?fix(portal):=20preset=20"tudo"=20ancora?= =?UTF-8?q?=20na=20primeira=20contribui=C3=A7=C3=A3o=20em=20vez=20de=20cai?= =?UTF-8?q?r=20na=20janela=20semanal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Livewire/CommunityRetrospectivePage.php | 19 +++++++++++++++++-- .../CommunityRetrospectivePageTest.php | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php index 017adfe37..ecbe00a6a 100644 --- a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php +++ b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php @@ -80,11 +80,11 @@ public function setPreset(string $preset): void { $this->since = match ($preset) { 'mes' => CarbonImmutable::now()->subMonth()->toDateString(), - 'tudo' => null, + 'tudo' => $this->firstContributionDate(), default => CarbonImmutable::now()->startOfWeek(CarbonInterface::MONDAY)->subWeek()->toDateString(), }; - $this->until = $preset === 'tudo' ? null : CarbonImmutable::now()->toDateString(); + $this->until = CarbonImmutable::now()->toDateString(); } public function render(): View @@ -133,6 +133,21 @@ private function toggleAware(array $selected, array $all, string $value): array return count($next) === count($all) ? [] : $next; } + /** + * Data da contribuição mais antiga do tenant, para o preset "tudo" cobrir o + * histórico real. Sem registros, cai no mesmo default semanal do render(). + */ + private function firstContributionDate(): string + { + $first = GithubContribution::query() + ->where('tenant_id', $this->tenantId) + ->min('occurred_at'); + + return is_string($first) && $first !== '' + ? CarbonImmutable::parse($first)->toDateString() + : CarbonImmutable::now()->startOfWeek(CarbonInterface::MONDAY)->subWeek()->toDateString(); + } + /** * @return list */ diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php index d51fd83de..2e67281b8 100644 --- a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php +++ b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php @@ -83,3 +83,22 @@ ->set('hideBots', false) ->assertSet('hideBots', false); }); + +it('preset "tudo" ancora o período na primeira contribuição e traz o histórico inteiro', function (): void { + $this->travelTo(CarbonImmutable::parse('2026-06-04 10:00:00')); + + GithubContribution::factory()->for($this->tenant)->create([ + 'actor_login' => 'pioneira', 'actor_id' => 1, 'type' => ContributionType::Commit, + 'external_ref' => 'commit:abc', 'occurred_at' => '2020-03-30 02:13:45', + ]); + GithubContribution::factory()->for($this->tenant)->create([ + 'actor_login' => 'recente', 'actor_id' => 2, 'type' => ContributionType::Issue, + 'external_ref' => 'issue:1', 'occurred_at' => '2026-06-02', + ]); + + livewire(CommunityRetrospectivePage::class) + ->call('setPreset', 'tudo') + ->assertSet('since', '2020-03-30') + ->assertSee('pioneira') + ->assertSee('recente'); +}); From a258b5ca56f23bd66bd61137144dca922c29df24 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Sat, 6 Jun 2026 11:00:59 -0300 Subject: [PATCH 24/27] =?UTF-8?q?fix(portal):=20preset=20"tudo"=20respeita?= =?UTF-8?q?=20repos=20selecionados=20ao=20ancorar=20o=20per=C3=ADodo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Livewire/CommunityRetrospectivePage.php | 6 ++++-- .../CommunityRetrospectivePageTest.php | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php index ecbe00a6a..30dc0e8bf 100644 --- a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php +++ b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php @@ -134,13 +134,15 @@ private function toggleAware(array $selected, array $all, string $value): array } /** - * Data da contribuição mais antiga do tenant, para o preset "tudo" cobrir o - * histórico real. Sem registros, cai no mesmo default semanal do render(). + * Data da contribuição mais antiga dentro do escopo atual (tenant + 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() ->where('tenant_id', $this->tenantId) + ->when($this->repos !== [], fn ($query) => $query->whereIn('repo', $this->repos)) ->min('occurred_at'); return is_string($first) && $first !== '' diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php index 2e67281b8..57878f42e 100644 --- a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php +++ b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php @@ -102,3 +102,23 @@ ->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()->for($this->tenant)->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()->for($this->tenant)->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'); +}); From 57acac5c3f45e3f5f69bfccbd0651615628fc95c Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Sat, 6 Jun 2026 11:16:45 -0300 Subject: [PATCH 25/27] =?UTF-8?q?feat(portal):=20barra=20de=20composi?= =?UTF-8?q?=C3=A7=C3=A3o=20e=20=C3=ADcones=20nos=20cards=20de=20contribuid?= =?UTF-8?q?or?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../portal/resources/css/retrospective.css | 25 ++++++++++++++++- .../components/retro/activity-chips.blade.php | 25 +++++++++++++---- .../retro/composition-bar.blade.php | 27 +++++++++++++++++++ .../components/retro/person-card.blade.php | 1 + .../CommunityRetrospectivePageTest.php | 3 ++- 5 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 app-modules/portal/resources/views/components/retro/composition-bar.blade.php diff --git a/app-modules/portal/resources/css/retrospective.css b/app-modules/portal/resources/css/retrospective.css index 8292da482..7a280ee56 100644 --- a/app-modules/portal/resources/css/retrospective.css +++ b/app-modules/portal/resources/css/retrospective.css @@ -434,6 +434,22 @@ background: var(--del); } +/* barra de composição (DNA da pessoa) */ +.retro .compbar { + display: flex; + height: 8px; + margin-top: 14px; + border-radius: 999px; + overflow: hidden; + background: var(--surface-2); + border: 1px solid var(--border); +} +.retro .compbar .seg { + height: 100%; + min-width: 3px; + transition: width 0.4s cubic-bezier(0.2, 0.7, 0.2, 1); +} + /* pr row */ .retro .tpr { display: flex; @@ -570,7 +586,7 @@ flex: none; width: 118px; display: inline-flex; - align-items: center; + align-items: flex-start; gap: 7px; padding-top: 3px; font-family: 'JetBrains Mono', monospace; @@ -579,6 +595,13 @@ text-transform: uppercase; color: color-mix(in srgb, var(--c) 80%, white); } +.retro .act-ic { + flex: none; + width: 14px; + height: 14px; + margin-top: 1px; + color: var(--c); +} .retro .act-items { display: flex; flex-wrap: wrap; diff --git a/app-modules/portal/resources/views/components/retro/activity-chips.blade.php b/app-modules/portal/resources/views/components/retro/activity-chips.blade.php index 1c14db3da..951824c6b 100644 --- a/app-modules/portal/resources/views/components/retro/activity-chips.blade.php +++ b/app-modules/portal/resources/views/components/retro/activity-chips.blade.php @@ -3,7 +3,10 @@
@if (count($person['pr_refs']))
- Abriu PR + @foreach ($person['pr_refs'] as $ref) 0)
- Revisou + >Revisou{{ $person['reviews'] }} reviews @@ -28,7 +33,13 @@ class="ref" @endif @if (count($person['issue_refs']))
- Abriu issue + @foreach ($person['issue_refs'] as $ref) 0)
- Comentou + >Comentou{{ $person['comments'] }} comentários @@ -52,7 +65,9 @@ class="ref" @endif @if ($person['commits'] > 0)
- Commitou + >Commitou{{ $person['commits'] }} commits diff --git a/app-modules/portal/resources/views/components/retro/composition-bar.blade.php b/app-modules/portal/resources/views/components/retro/composition-bar.blade.php new file mode 100644 index 000000000..48340d733 --- /dev/null +++ b/app-modules/portal/resources/views/components/retro/composition-bar.blade.php @@ -0,0 +1,27 @@ +@props (['person']) +@php + $segments = array_values( + array_filter( + [ + ['color' => 'var(--t-pr)', 'count' => $person['prs'], 'label' => 'PRs'], + ['color' => 'var(--t-review)', 'count' => $person['reviews'], 'label' => 'reviews'], + ['color' => 'var(--t-issue)', 'count' => $person['issues'], 'label' => 'issues'], + ['color' => 'var(--t-comment)', 'count' => $person['comments'], 'label' => 'comentários'], + ['color' => 'var(--t-commit)', 'count' => $person['commits'], 'label' => 'commits'], + ], + fn(array $segment): bool => $segment['count'] > 0, + ), + ); + $sum = array_sum(array_column($segments, 'count')) ?: 1; +@endphp +@if (count($segments)) + +@endif diff --git a/app-modules/portal/resources/views/components/retro/person-card.blade.php b/app-modules/portal/resources/views/components/retro/person-card.blade.php index 7ecb5e6b0..09e3d5b75 100644 --- a/app-modules/portal/resources/views/components/retro/person-card.blade.php +++ b/app-modules/portal/resources/views/components/retro/person-card.blade.php @@ -28,5 +28,6 @@ class="name" #{{ $rank }} @endif
+
diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php index 57878f42e..b7e27cbf1 100644 --- a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php +++ b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php @@ -23,7 +23,8 @@ livewire(CommunityRetrospectivePage::class, ['since' => '2026-06-01', 'until' => '2026-06-07']) ->assertOk() - ->assertSee('maria'); + ->assertSee('maria') + ->assertSee('compbar'); }); it('usa a janela padrão (segunda passada → hoje) quando sem parâmetros', function (): void { From 377f18286e435901c51608c4f2a13041f215bcdb Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Sat, 6 Jun 2026 11:20:55 -0300 Subject: [PATCH 26/27] =?UTF-8?q?fix(portal):=20=C3=ADcones=20dos=20r?= =?UTF-8?q?=C3=B3tulos=20via=20mask=20CSS=20(evita=20prettier=20quebrar=20?= =?UTF-8?q?SVG=20inline)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../portal/resources/css/retrospective.css | 22 ++++++++++-- .../components/retro/activity-chips.blade.php | 35 ++++++------------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/app-modules/portal/resources/css/retrospective.css b/app-modules/portal/resources/css/retrospective.css index 7a280ee56..b086739ca 100644 --- a/app-modules/portal/resources/css/retrospective.css +++ b/app-modules/portal/resources/css/retrospective.css @@ -595,12 +595,30 @@ text-transform: uppercase; color: color-mix(in srgb, var(--c) 80%, white); } -.retro .act-ic { +.retro .act-h::before { + content: ''; flex: none; width: 14px; height: 14px; margin-top: 1px; - color: var(--c); + background-color: var(--c); + -webkit-mask: var(--icon) center / contain no-repeat; + mask: var(--icon) center / contain no-repeat; +} +.retro .act-pr { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z'/%3E%3C/svg%3E"); +} +.retro .act-review { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.671 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.831.88 9.577.43 8.899a1.62 1.62 0 0 1 0-1.798c.45-.677 1.367-1.931 2.637-3.023C4.329 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z'/%3E%3C/svg%3E"); +} +.retro .act-issue { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z'/%3E%3Cpath d='M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z'/%3E%3C/svg%3E"); +} +.retro .act-comment { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z'/%3E%3C/svg%3E"); +} +.retro .act-commit { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z'/%3E%3C/svg%3E"); } .retro .act-items { display: flex; diff --git a/app-modules/portal/resources/views/components/retro/activity-chips.blade.php b/app-modules/portal/resources/views/components/retro/activity-chips.blade.php index 951824c6b..16f20687e 100644 --- a/app-modules/portal/resources/views/components/retro/activity-chips.blade.php +++ b/app-modules/portal/resources/views/components/retro/activity-chips.blade.php @@ -2,11 +2,8 @@ @php ($stateColor = fn($state) => [ 'merged' => 'var(--st-merged)', 'open' => 'var(--st-open)', 'closed' => 'var(--st-closed)' ][$state ?? ''] ?? 'var(--st-open)')
@if (count($person['pr_refs'])) -
- +
+ Abriu PR @foreach ($person['pr_refs'] as $ref) @endif @if ($person['reviews'] > 0) -
- + Revisou{{ $person['reviews'] }} reviews
@endif @if (count($person['issue_refs'])) -
- +
+ Abriu issue @foreach ($person['issue_refs'] as $ref) @endif @if ($person['comments'] > 0) -
- + Comentou{{ $person['comments'] }} comentários
@endif @if ($person['commits'] > 0) -
- + Commitou{{ $person['commits'] }} commits From 8fd27620bed469283628dce829c16920b6e6ed0e Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Sat, 6 Jun 2026 12:21:14 -0300 Subject: [PATCH 27/27] =?UTF-8?q?fix(integration-github):=20endurece=20web?= =?UTF-8?q?hook=20e=20ingest=C3=A3o=20(secret,=20case=20do=20repo,=20seam)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VerifyGithubSignature recusa (500) quando o secret não está configurado, fechando o vetor de webhook forjável com HMAC de chave vazia - full_name canonicalizado em minúsculas (mutator) e o repo do payload normalizado no ProjectGithubEvent, evitando perda silenciosa de eventos e fragmentação por diferença de case - GithubContributionRecorded só dispara na criação (wasRecentlyCreated), evitando re-emissão da seam em edições/replays do webhook --- .../src/Contributions/RecordContribution.php | 5 ++- .../src/Models/GithubRepository.php | 14 ++++++++ .../src/Webhook/ProjectGithubEvent.php | 4 ++- .../src/Webhook/VerifyGithubSignature.php | 7 +++- .../tests/Feature/GithubRepositoryTest.php | 6 ++++ .../tests/Feature/GithubWebhookTest.php | 32 +++++++++++++++++++ 6 files changed, 65 insertions(+), 3 deletions(-) diff --git a/app-modules/integration-github/src/Contributions/RecordContribution.php b/app-modules/integration-github/src/Contributions/RecordContribution.php index 27e594853..2ecafc95e 100644 --- a/app-modules/integration-github/src/Contributions/RecordContribution.php +++ b/app-modules/integration-github/src/Contributions/RecordContribution.php @@ -41,7 +41,10 @@ public function execute( ], ); - if ($emit) { + // Só emite na criação. Webhooks de edição/replay reprocessam a mesma + // contribuição (updateOrCreate atualiza a linha) e não devem re-disparar a + // seam — evita recompensas duplicadas em listeners downstream. + if ($emit && $contribution->wasRecentlyCreated) { event(new GithubContributionRecorded($contribution)); } diff --git a/app-modules/integration-github/src/Models/GithubRepository.php b/app-modules/integration-github/src/Models/GithubRepository.php index 69dbee0a9..2c0a75473 100644 --- a/app-modules/integration-github/src/Models/GithubRepository.php +++ b/app-modules/integration-github/src/Models/GithubRepository.php @@ -9,6 +9,7 @@ use He4rt\IntegrationGithub\Database\Factories\GithubRepositoryFactory; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -43,6 +44,19 @@ protected static function newFactory(): GithubRepositoryFactory return GithubRepositoryFactory::new(); } + /** + * GitHub trata owner/repo como case-insensitive; guardamos sempre em minúsculas + * (e sem espaços) para o matching com o payload do webhook e a retrospectiva. + * + * @return Attribute + */ + protected function fullName(): Attribute + { + return Attribute::make( + set: fn (string $value): string => mb_strtolower(mb_trim($value)), + ); + } + /** * @param Builder $query * @return Builder diff --git a/app-modules/integration-github/src/Webhook/ProjectGithubEvent.php b/app-modules/integration-github/src/Webhook/ProjectGithubEvent.php index e05986d3b..8b096c629 100644 --- a/app-modules/integration-github/src/Webhook/ProjectGithubEvent.php +++ b/app-modules/integration-github/src/Webhook/ProjectGithubEvent.php @@ -23,7 +23,9 @@ public function __construct( */ public function execute(string $event, array $payload): void { - $repo = $this->str($payload, 'repository.full_name'); + // Canonicaliza para minúsculas (igual ao cadastro) — sem isso, um repo com + // case diferente no payload não casaria a allowlist e o evento seria perdido. + $repo = mb_strtolower($this->str($payload, 'repository.full_name')); if ($repo === '') { return; diff --git a/app-modules/integration-github/src/Webhook/VerifyGithubSignature.php b/app-modules/integration-github/src/Webhook/VerifyGithubSignature.php index c0a1dc51a..214e93cad 100644 --- a/app-modules/integration-github/src/Webhook/VerifyGithubSignature.php +++ b/app-modules/integration-github/src/Webhook/VerifyGithubSignature.php @@ -12,11 +12,16 @@ final class VerifyGithubSignature { public function handle(Request $request, Closure $next): Response { + $secret = config()->string('services.github.webhook_secret'); + + // Fail-safe: sem secret configurado, um HMAC de chave vazia seria forjável + // por qualquer um. Recusa em vez de aceitar silenciosamente. + abort_if($secret === '', 500, 'GitHub webhook secret is not configured'); + $signature = $request->header('X-Hub-Signature-256'); abort_if($signature === null, 403, 'Missing X-Hub-Signature-256'); - $secret = config()->string('services.github.webhook_secret'); $expected = 'sha256='.hash_hmac('sha256', $request->getContent(), $secret); abort_unless(hash_equals($expected, $signature), 403, 'Invalid signature'); diff --git a/app-modules/integration-github/tests/Feature/GithubRepositoryTest.php b/app-modules/integration-github/tests/Feature/GithubRepositoryTest.php index 1be138c0d..94b64e2de 100644 --- a/app-modules/integration-github/tests/Feature/GithubRepositoryTest.php +++ b/app-modules/integration-github/tests/Feature/GithubRepositoryTest.php @@ -36,3 +36,9 @@ expect(GithubRepository::query()->enabled()->count())->toBe(2); }); + +it('canonicaliza full_name para minúsculas e sem espaços ao salvar', function (): void { + $repo = GithubRepository::factory()->create(['full_name' => ' He4rt/HeartDevs.com ']); + + expect($repo->refresh()->full_name)->toBe('he4rt/heartdevs.com'); +}); diff --git a/app-modules/integration-github/tests/Feature/GithubWebhookTest.php b/app-modules/integration-github/tests/Feature/GithubWebhookTest.php index b35c345cf..e3df5acb0 100644 --- a/app-modules/integration-github/tests/Feature/GithubWebhookTest.php +++ b/app-modules/integration-github/tests/Feature/GithubWebhookTest.php @@ -58,6 +58,38 @@ function prWebhookPayload(string $repo = 'he4rt/heartdevs.com', int $number = 1, ->and(GithubContribution::query()->count())->toBe(0); }); +it('rejeita o webhook quando o secret não está configurado (fail-safe)', function (): void { + config(['services.github.webhook_secret' => '']); + GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']); + + postGithubWebhook('pull_request', prWebhookPayload(), secret: '') + ->assertServerError(); + + expect(GithubEventLog::query()->count())->toBe(0) + ->and(GithubContribution::query()->count())->toBe(0); +}); + +it('projeta a contribuição mesmo quando o case do repo no payload difere do cadastro', function (): void { + GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']); + + postGithubWebhook('pull_request', prWebhookPayload('He4rt/HeartDevs.com'))->assertSuccessful(); + + $contribution = GithubContribution::query()->where('external_ref', 'pr:1')->sole(); + + expect($contribution->repo)->toBe('he4rt/heartdevs.com'); +}); + +it('emite o evento apenas na criação, não em reprocessamentos da mesma contribuição', function (): void { + Event::fake([GithubContributionRecorded::class]); + GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']); + + postGithubWebhook('pull_request', prWebhookPayload(), delivery: 'delivery-1')->assertSuccessful(); + postGithubWebhook('pull_request', prWebhookPayload(), delivery: 'delivery-2')->assertSuccessful(); + + expect(GithubContribution::query()->where('external_ref', 'pr:1')->count())->toBe(1); + Event::assertDispatchedTimes(GithubContributionRecorded::class, 1); +}); + it('grava no lake e projeta a contribuição para repo na allowlist, emitindo o evento', function (): void { Event::fake([GithubContributionRecorded::class]); $repo = GithubRepository::factory()->create(['full_name' => 'he4rt/heartdevs.com']);