Task Breakdown: Integrations — GitHub App
Feature ID: integrations-github-app
Plan: ./plan.md
Status: Done (retrospective — surface already shipped)
Last updated: 2026-05-08
Phase 1 — Module + config (shipped)
- T1. Nest module at
apps/api/src/integrations/github-app/github-app.module.ts- Imports
DatabaseModule,HttpModule,AuthModule,WorkModule,ImportModule. - Providers:
GitHubAppService,GitHubAppOnboardingService,GitHubAppSyncService. - Controllers:
GitHubAppController,GitHubAppWebhookController. - Exports the three services so the agent-onboarding flow can re-use them.
- Imports
- T2.
config.githubApp.*inapps/api/src/config/constants.ts- Reads
GITHUB_APP_ID,GITHUB_APP_CLIENT_ID,GITHUB_APP_CLIENT_SECRET,GITHUB_APP_PRIVATE_KEY(with\\n→\nrewrite),GITHUB_APP_WEBHOOK_SECRET,GITHUB_APP_SLUG(default'ever-works'),GITHUB_APP_SETUP_URL/GITHUB_APP_CALLBACK_URL(withwebAppUrldefaults).
- Reads
Phase 2 — Auth + GitHub HTTP layer (shipped)
- T3.
GitHubAppServiceatapps/api/src/integrations/github-app/github-app.service.tsgetConfiguration— five-field tuple.getUserAuthorizationUrl(state)— full URL builder.exchangeUserCode(code)— POST/login/oauth/access_token, throwsUnauthorizedExceptionondata.error,BadRequestExceptionwhen no token returned.getAuthenticatedGithubUser(accessToken)— GET/user+ email-fallback viaresolveGitHubAccountEmail, normalised output shape.getInstallation(installationId)— GET/app/installations/:idwith app JWT.createInstallationAccessToken(installationId)— proxy torequestGitHubAppInstallationAccessToken(agent helper).listInstallationRepositories(installationId)— pagination loop (per_page=100, break on short page or total_count met).verifyWebhookSignature(rawBody, signature?)— short-circuits tofalsewhen secret unset.getAppJwt/getCredentials— private helpers; throws hard Error when credentials missing.
Phase 3 — User + installation onboarding (shipped)
- T4.
GitHubAppOnboardingServiceatapps/api/src/integrations/github-app/github-app-onboarding.service.tsbeginSetup— firstgetInstallation, thenupsertFromGithub, then HMAC-sign 10-minute state payload, then returngetUserAuthorizationUrl(state)envelope.completeUserAuth— verifyState → exchangeUserCode → getAuthenticatedGithubUser → findOrCreateLocalUser → upsertProviderAccount → upsertLink (user-link table) → re-getInstallation → re-upsertFromGithub → claimOwnershipIfUnassigned → throw if claim returns null.findOrCreateLocalUser— four-step chain (user-link → auth-account → email lookup with verified-email gate → create), with username uniqueness viaresolveUniqueUsernameand synthetic noreply email when no verified address available.signState/verifyState— base64url-encoded payload + HMAC-SHA-256 signature + 10-minute TTL + constant-time comparison viatimingSafeEqualafter a length guard.normalizeRedirectTo— only strings starting with/survive.
Phase 4 — Repo sync + onboarding handoff (shipped)
- T5.
GitHubAppSyncServiceatapps/api/src/integrations/github-app/github-app-sync.service.tslistInstallationsForUser(userId)— list rows + fan-out tolistForInstallationviaPromise.all.syncInstallation(installationId, userId?)— short-circuit on missing/deleted/suspended/ownership-mismatch; otherwise mint installation token, list repos, replace per-installation repo list withselected: trueon every row.onboardInstallationRepository(installationId, repositoryId, user)— short-circuit branches; analyze repo with installation token; gate ondetectedType === 'data_repo'; forward toWorkImportService.onboardLinkedRepositorywithmode: 'github_app_installation'auth payload.handleWebhook(eventName, payload)—installationandinstallation_repositoriesonly. Soft-delete oninstallation.deleted. Suspend / unsuspend handling viasuspendedAt. Re-sync oncreated/new_permissions_accepted/unsuspend.
Phase 5 — Controllers + webhook (shipped)
- T6.
GitHubAppControlleratapps/api/src/integrations/github-app/github-app.controller.tssetup(@Public) — query DTO, returns{url}envelope.callback(@Public) — query DTO, returns{...auth, installationId, redirectTo}after issuing the session viaAuthProvider.issueSession.listInstallations— proxies togitHubAppSyncService.listInstallationsForUser(req.user.userId).syncInstallation— proxies;null→ 401 Unauthorized.onboardRepository—null→ 404;{status:'error', message}→ 400.
- T7.
GitHubAppWebhookControlleratapps/api/src/integrations/github-app/github-app-webhook.controller.tshandleWebhook(@Public) — 400 on missing event header, 400 on missingreq.rawBody, 401 on signature mismatch.- Returns
{ ok: true }on success.
Phase 6 — DTOs (shipped)
- T8. Query-string DTOs in
dto/github-app.dto.tsGitHubAppSetupQueryDto:installation_idrequired string,setup_action∈{install, request}if present,redirectTooptional string.GitHubAppCallbackQueryDto:coderequired,staterequired.
Phase 7 — Entities + repositories (shipped via agent package)
- T9.
GitHubAppInstallationentity atpackages/agent/src/entities/github-app-installation.entity.ts - T10.
GitHubAppInstallationRepositoryentity atpackages/agent/src/entities/github-app-installation-repository.entity.ts - T11.
GitHubAppUserLinkentity atpackages/agent/src/entities/github-app-user-link.entity.ts - T12. Repository classes for all three entities at
packages/agent/src/database/repositories/GitHubAppInstallationRepository.upsertFromGithubw/ unique-constraint retry,claimOwnershipIfUnassignedw/WHERE createdByUserId IS NULLatomicity guarantee,markSuspended/markDeleted,listByCreatedByUserId/findByInstallationId.GitHubAppInstallationRepoRepository.replaceForInstallation(atomic delete-then-insert) andlistForInstallation.GitHubAppUserLinkRepository.findByGithubUserId/upsertLink.
Phase 8 — Tests (shipped)
- T13. Service-level unit tests
github-app.service.spec.ts— JWT minting, OAuth code exchange, GitHub user resolution, installation token mint, pagination loop, webhook signature short-circuit, hard Error when credentials missing.github-app-onboarding.service.spec.ts— state HMAC sign + verify (round-trip + tamper / TTL / payload-shape rejection), find-or-create chain (link → auth-account → email-with-verify- gate → create), uniqueness suffixing, claim-ownership negative branch surfacing as 400, redirectTo normalisation.github-app-sync.service.spec.ts— soft-deleted / suspended / not-owned short-circuit on the three service methods;replaceForInstallationshape (selected: true, owner-fallback, full-name-fallback); webhook event handling (deleted / suspend / unsuspend / created / repo-list / missing-id / unsupported-event).
- T14. Controller-level unit tests — 22 tests across
github-app.controller.spec.ts(15) andgithub-app-webhook.controller.spec.ts(7). Mocks@ever-works/agent/{database,entities,import,services}plus the@src/authbarrel — see PR #502.
Outstanding follow-ups
- T15 (OQ-1) Promote
getCredentialsError to aServiceUnavailableException(orInternalServerErrorExceptionwith a structured body) so missing-credentials returns 503 instead of an opaque 500. - T16 (OQ-2) Add a
GitHubAppSyncGuardthat short-circuits to 503 when any required env var is unset, mirroringCrmSyncGuard. Apply toGitHubAppController(the publicsetup/callbackendpoints would still need to bypass the guard via@Publicsemantics). - T17 (OQ-3) Render the email-not-verified callback 401 as
a friendlier setup-error page in
apps/webrather than the raw 401 JSON. - T18 (OQ-4) Derive the synthetic noreply suffix from
webAppUrl()(e.g.@users.noreply.<host>) rather than the hard-coded@users.noreply.ever.worksso multi-deployment setups don't share an email namespace. - T19 (OQ-5) Decide on second-user-installs-same-app
semantics: today,
claimOwnershipIfUnassignedreturns the existing row whencreatedByUserIdis already set. Either returnConflictfor the second user, OR teach the installation row to support multi-owner / per-org mapping. - T20 (OQ-6) Log unsupported webhook event names at
debugso payload-shape changes (e.g. GitHub renaming events) are spottable in production logs. - T21 (OQ-7) Decide on activity-log emission for
installation lifecycle events (
installation.created,installation.deleted,installation.suspended,installation.unsuspend). Today nothing is emitted; an audit trail for org-level GitHub-App actions may be a compliance requirement. - T22 (OQ-8) Move the "data_repo only" message into a shared constant / i18n key. Today the message is hard-coded English at the service layer.
- T23 (OQ-9)
replaceForInstallationdefaultsselected: truefor every repo; orgs with hundreds of repos see all of them in the platform UI. Preserve priorselected: falserows by reading the existing list before replacement. - T24 Add a Postgres-container integration test that
exercises the three repositories end-to-end:
upsertFromGithub→claimOwnershipIfUnassigned→ atomicity under concurrent claims;replaceForInstallationatomicity under concurrent webhook deliveries;markDeletedordering withmarkSuspended. Today only mocked unit tests cover these surfaces. - T25 Add an e2e test that posts crafted GitHub webhook
payloads (with a real HMAC signature) to
/api/github-app/webhooksand asserts the resulting DB state. Today only the controller's input-validation paths are unit-tested — the end-to-end webhook → repository pipeline is exercised only via the service-level mocks. - T26 Add a
github-app.module.spec.tsNest-module wiring test (the only remaining "module-level" gap in the folder per the row inCOVERAGE-TRACKER.md). Defer untilapps/apihas a precedent for module-wiring tests; today@nestjs/testingis used at the controller/service level only. - T27 GitHub Enterprise Server support — add
GITHUB_APP_BASE_URLandGITHUB_APP_API_BASE_URLenv vars and thread them through everyfirstValueFrom(httpService.*)call inGitHubAppService. - T28 Bulk repo-onboarding — extend
onboardInstallationRepositoryto accept an array (or add a sibling endpoint), and move the analyzer + onboarding work into a Trigger.dev queue perPlan §10. - T29 Allow non-
data_repotypes — coordinate withWorkImportService.onboardLinkedRepositoryso website / agent / pipeline repos can be onboarded from the GitHub App surface directly. Currently those require manual Work creation. - T30 Update
apps/api's OpenAPI documentation to include the GitHub-App endpoints. Today the OpenAPI generator picks them up automatically via the controller decorators, but thesetupandcallbackroutes are@Public()and the Swagger tags + descriptions could be tightened (e.g. clarifying thatsetupreturns a JSON envelope, not a redirect).