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..fc77a6161 --- /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` (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) | + +## 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..bb34e96ec --- /dev/null +++ b/app-modules/integration-github/database/factories/GithubContributionFactory.php @@ -0,0 +1,46 @@ + + */ +final class GithubContributionFactory extends Factory +{ + protected $model = GithubContribution::class; + + /** + * @return array + */ + 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), + '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..eb09c0c15 --- /dev/null +++ b/app-modules/integration-github/database/factories/GithubRepositoryFactory.php @@ -0,0 +1,40 @@ + + */ +final class GithubRepositoryFactory extends Factory +{ + protected $model = GithubRepository::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + '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..6c210e231 --- /dev/null +++ b/app-modules/integration-github/database/migrations/2026_06_04_000001_create_github_repositories_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $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'); + }); + } + + 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..820c56a30 --- /dev/null +++ b/app-modules/integration-github/database/migrations/2026_06_04_000002_create_github_contributions_table.php @@ -0,0 +1,39 @@ +uuid('id')->primary(); + $table->foreignUuid('tenant_id')->constrained('tenants'); + $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(); + + // 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(['tenant_id', '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..9be4fc99e --- /dev/null +++ b/app-modules/integration-github/docs/adr/0001-github-community-contributions.md @@ -0,0 +1,58 @@ +# 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(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 + +`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` **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, scoped per tenant + +`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 + +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; 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/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..929ae663c --- /dev/null +++ b/app-modules/integration-github/src/Backfill/BackfillRepository.php @@ -0,0 +1,235 @@ +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 $tenantId, string $repo): void + { + $this->paginate( + fn (int $page): Request => new ListPullRequests($repo, $page, self::PER_PAGE), + 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($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, + '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($tenantId, $repo, $number); + }, + ); + } + + 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 ($tenantId, $repo, $number): void { + $login = $this->login($review); + + $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), + ]); + }, + ); + } + + 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 ($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($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, + 'is_bot' => $this->isBot($login), + ]); + }, + ); + } + + 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 ($tenantId, $repo): void { + $login = $this->login($comment); + + $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), + ]); + }, + ); + } + + 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 ($tenantId, $repo): void { + $login = $this->login($comment); + + $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), + ]); + }, + ); + } + + 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 ($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'] : []; + + $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($tenantId, $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 $tenantId, + string $repo, + ContributionType $type, + string $externalRef, + string $actorLogin, + ?int $actorId, + string $occurredAt, + ?string $targetRef, + array $metadata, + ): void { + $this->recorder->execute($tenantId, $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..c8771a770 --- /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); + } 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..27e594853 --- /dev/null +++ b/app-modules/integration-github/src/Contributions/RecordContribution.php @@ -0,0 +1,50 @@ + $metadata + */ + public function execute( + string $tenantId, + string $repo, + ContributionType $type, + string $externalRef, + string $actorLogin, + ?int $actorId, + string $occurredAt, + ?string $targetRef, + array $metadata, + bool $emit = false, + ): GithubContribution { + $contribution = GithubContribution::query()->updateOrCreate( + ['tenant_id' => $tenantId, '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..e030e77e7 --- /dev/null +++ b/app-modules/integration-github/src/Models/GithubContribution.php @@ -0,0 +1,63 @@ +|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; + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + 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..69dbee0a9 --- /dev/null +++ b/app-modules/integration-github/src/Models/GithubRepository.php @@ -0,0 +1,65 @@ + */ + use HasFactory; + use HasUuids; + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + 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..e05986d3b --- /dev/null +++ b/app-modules/integration-github/src/Webhook/ProjectGithubEvent.php @@ -0,0 +1,191 @@ + $payload + */ + public function execute(string $event, array $payload): void + { + $repo = $this->str($payload, 'repository.full_name'); + + if ($repo === '') { + return; + } + + // 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, + }; + } + } + + /** + * @return list + */ + private function tenantsTracking(string $repo): array + { + return GithubRepository::query() + ->enabled() + ->where('full_name', $repo) + ->pluck('tenant_id') + ->all(); + } + + /** + * @param array $payload + */ + 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($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, + '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 $tenantId, string $repo, array $payload): void + { + $review = $this->arr($payload, 'review'); + $login = $this->str($review, 'user.login', 'ghost'); + + $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); + } + + /** + * @param array $payload + */ + 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($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'), + 'is_bot' => str_ends_with($login, '[bot]'), + ], emit: true); + } + + /** + * @param array $payload + */ + 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($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]'), + ], emit: true); + } + + /** + * @param array $payload + */ + 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($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]'), + ], emit: true); + } + + /** + * @param array $payload + */ + private function push(string $tenantId, 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($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); + } + } + + /** + * @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..114bf51e9 --- /dev/null +++ b/app-modules/integration-github/tests/Feature/BackfillRepositoryTest.php @@ -0,0 +1,206 @@ +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. + * + * @param array $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(GithubRepository $repo): void +{ + resolve(BackfillRepository::class)->execute($repo); +} + +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($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') + ->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($this->repo); + backfill($this->repo); + + 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($this->repo); + + 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($this->repo); + + $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($this->repo); + + 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($this->repo); + + $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($this->repo); + + $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($this->repo)) + ->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($this->repo); + + $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..83a97dadf --- /dev/null +++ b/app-modules/integration-github/tests/Feature/GithubContributionTest.php @@ -0,0 +1,64 @@ +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() + ->and($contribution->tenant)->toBeInstanceOf(Tenant::class); +}); + +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()->for($tenant)->create([ + 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', + ]); +})->throws(QueryException::class); + +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()->for($tenant)->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..1be138c0d --- /dev/null +++ b/app-modules/integration-github/tests/Feature/GithubRepositoryTest.php @@ -0,0 +1,38 @@ +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() + ->and($repo->tenant)->toBeInstanceOf(Tenant::class); +}); + +it('impede full_name duplicado no mesmo tenant', function (): void { + $tenant = Tenant::factory()->create(); + + 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(); + + 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..b35c345cf --- /dev/null +++ b/app-modules/integration-github/tests/Feature/GithubWebhookTest.php @@ -0,0 +1,129 @@ + '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]); + $repo = 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->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(); + + 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.-]+$/') + // 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), + ]); + } + + 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); + } 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..5f224534b --- /dev/null +++ b/app-modules/panel-admin/tests/Feature/Github/GithubRepositoryResourceTest.php @@ -0,0 +1,111 @@ +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); + // 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; +}); + +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(); + + $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 { + livewire(CreateGithubRepository::class) + ->fillForm(['full_name' => '4noobs']) + ->call('create') + ->assertHasFormErrors(['full_name']); +}); + +test('rejeita full_name duplicado no mesmo tenant', function (): void { + GithubRepository::factory()->create(['full_name' => 'he4rt/4noobs']); + + livewire(CreateGithubRepository::class) + ->fillForm(['full_name' => 'he4rt/4noobs']) + ->call('create') + ->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'], + 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..87db0f0bd --- /dev/null +++ b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php @@ -0,0 +1,58 @@ +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 !== '' + ? 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($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 68d8a9c17..de96a60af 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,8 @@ public function register(): void {} 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 new file mode 100644 index 000000000..c9dbf06f3 --- /dev/null +++ b/app-modules/portal/src/Retrospective/CommunityRetrospective.php @@ -0,0 +1,146 @@ +, + * people: list>, + * } + */ + 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)) + ->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..f2c366bca --- /dev/null +++ b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php @@ -0,0 +1,60 @@ + 'he4rt']); + $this->tenant = Tenant::factory()->create(['slug' => 'he4rt']); +}); + +it('mostra os contribuidores do período informado', function (): void { + 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], + ]); + + 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()->for($this->tenant)->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 (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()->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], + ]); + + 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..b9cfd1ead --- /dev/null +++ b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php @@ -0,0 +1,87 @@ +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 { + 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->tenant->id, $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 { + 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->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 { + 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->tenant->id, $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 { + 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(); + + 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), 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).