Files
kandev/apps/backend/internal/github/service_app_auth.go
T
João Salavisa 37b56ba11c feat(automations): reliable cron scheduling and webhook-driven push/CI triggers (#1972)
* feat(automations): reliable cron scheduling and webhook-driven push/CI triggers

Automations were "wounded": cron schedules mishandled timezones and some
never fired, and the GitHub push/CI trigger types were coming-soon stubs.

Cron (fix):
- The scheduler reduced every cron expression to a fixed interval derived
  only from a */N step in the minute/hour field, so any pinned schedule
  (0 9 * * *, 30 14 * * *, weekday ranges, day-of-month) never fired, and
  the configured timezone was parsed but never used.
- Replace the interval approximation with robfig/cron/v3: shouldFire now
  computes the true next fire time in the configured timezone (DST-aware,
  defaulting to UTC for host-independence), anchored to last-evaluated or
  creation time. Dead interval-parsing helpers and shorthand constants
  removed.

GitHub push + CI triggers (feat):
- Implement push and CI-failure triggers end to end via the existing
  per-workspace GitHub App webhook plumbing (#1810): the HMAC-verified
  webhook service now parses push and completed check_run events, resolves
  the installation to its workspaces, and publishes normalized bus events.
- A new automation subscriber matches enabled github_push / github_ci
  triggers by workspace + repo + branch/conclusion/check-name filters and
  fires them (deduped by commit SHA / check-run id). The old poll-based
  stub evaluators are removed; the PR poll path is unchanged.
- Subscribe the App manifest to push/check_run and enable both trigger
  types in the registry (CI defaults to the failure conclusion).

Docs:
- Update docs/public/feature-status.md and automation-and-mcp.md so the
  cron and push/CI rows reflect shipped reality.

Tests: regression tests pin the previously-never-firing cron shapes,
timezone/DST behavior, and host-independence; backend tests cover the
webhook parse->publish and subscriber match/fire/dedup flows for both
push and CI-failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(automations): populate push.message and ci.url placeholders in fired trigger data

Address PR review blockers: the default push and CI automation prompts
reference {{push.message}} and {{ci.url}}, which the interpolator maps to
data["message"] / data["html_url"] — but the webhook subscriber never
populated those fields, so both always rendered empty.

- Parse head_commit.message (push) and check_run.html_url (CI) in the
  GitHub webhook service and carry them on the published bus payloads.
- Include "message" / "html_url" in the fired trigger data so the
  placeholders resolve; extend the webhook and subscriber tests to assert
  both flow through end to end.

Non-blocking review cleanups:
- Replace the misleading reuse of matchesAuthors for CI conclusions/check
  names with a clearly-named matchesFilterValue helper.
- Drop dead context.Background() line and its unused import from the cron
  regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(automations): scope webhook triggers to active installs and per-branch dedup

Address CodeRabbit review on the push/CI webhook triggers:

- Security: resolveWorkspaceIDs now only returns connections whose status is
  active, so a suspended or revoked GitHub App installation can no longer fire
  push/CI automations.
- Correctness: include the branch in the push dedup key
  (push:owner/name@branch@sha) so the same commit pushed to two matching
  branches fires once per branch instead of being suppressed.
- Correctness: add an optional head-branch filter to github_ci triggers and
  gate CI matching on it, matching the push trigger's branch-scoping.
- Stability: make the webhook subscriber's Start retryable — a subscription
  failure rolls back partial subscriptions and leaves the subscriber
  un-started so a later Start re-attempts, instead of permanently disabling
  push/CI processing.
- Tests: cover multi-connection workspace resolution (dedup + inactive
  filtering), per-branch push dedup, and CI branch filtering.
- Docs: note the CI branch filter and per-branch push dedup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(automations): cover webhook subscriber Start retry-after-failure path

Add TestGitHubWebhookSubscriberStartRetryableAfterFailure with a minimal
flaky bus whose second Subscribe fails, pinning the retry contract: a
subscription failure rolls back the partial subscription and leaves the
subscriber un-started, and a later Start re-attempts and succeeds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 10:24:57 +01:00

634 lines
22 KiB
Go

package github
import (
"context"
"errors"
"fmt"
"strings"
)
type githubAppRuntime struct {
registrationID string
source DeploymentAppSource
generation int64
appID int64
appClient *AppClient
installationTokens *InstallationTokenCache
installationProvider *CachedInstallationCredentialProvider
appInstallationAuth *AppInstallationService
personalAuth *PersonalAuthService
webhookAuth *GitHubWebhookService
}
type DeploymentAppRuntimeSnapshot struct {
RegistrationID string
Source DeploymentAppSource
Ready bool
AppID int64
Generation int64
}
func (s *Service) ValidateAppRegistrationRuntime(resolved ResolvedDeploymentAppConfig) error {
_, err := s.buildDeploymentAppRuntime(resolved)
return err
}
// ApplyAppRegistrationRuntime builds a complete App runtime before publishing
// it under its registration ID. A failure leaves every existing registration
// generation untouched.
func (s *Service) ApplyAppRegistrationRuntime(resolved ResolvedDeploymentAppConfig) error {
if s == nil {
return errors.New("GitHub service is not configured")
}
if resolved.Registration == nil || strings.TrimSpace(resolved.Registration.ID) == "" {
return errors.New("GitHub App registration ID is required")
}
runtime, err := s.buildDeploymentAppRuntime(resolved)
if err != nil {
return err
}
s.mu.Lock()
if s.appRegistrationRuntimes == nil {
s.appRegistrationRuntimes = make(map[string]*githubAppRuntime)
}
s.appRegistrationRuntimes[runtime.registrationID] = runtime
s.mu.Unlock()
if s.resolver != nil {
s.resolver.InvalidateAppRegistration(runtime.registrationID)
}
return nil
}
// InvalidateAppRegistrationRuntime removes only the expected generation. A
// delayed invalidation cannot tear down a newer key rotation.
func (s *Service) InvalidateAppRegistrationRuntime(registrationID string, generation int64) {
if s == nil || strings.TrimSpace(registrationID) == "" {
return
}
s.mu.Lock()
runtime := s.appRegistrationRuntimes[registrationID]
if runtime != nil && (generation <= 0 || runtime.generation == generation) {
delete(s.appRegistrationRuntimes, registrationID)
}
s.mu.Unlock()
if runtime != nil && (generation <= 0 || runtime.generation == generation) && s.resolver != nil {
s.resolver.InvalidateAppRegistration(registrationID)
}
}
func (s *Service) AppRegistrationRuntimeSnapshot(registrationID string) DeploymentAppRuntimeSnapshot {
runtime := s.currentAppRegistrationRuntime(registrationID)
if runtime == nil {
return DeploymentAppRuntimeSnapshot{RegistrationID: registrationID, Source: DeploymentAppSourceNone}
}
return DeploymentAppRuntimeSnapshot{
RegistrationID: registrationID, Source: runtime.source, Ready: true,
AppID: runtime.appID, Generation: runtime.generation,
}
}
func (s *Service) currentAppRegistrationRuntime(registrationID string) *githubAppRuntime {
if s == nil || strings.TrimSpace(registrationID) == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
return s.appRegistrationRuntimes[registrationID]
}
// InitializeAppRegistrationRuntimes independently loads every catalog entry.
// Invalid entries are reported together after all valid entries are active.
func (s *Service) InitializeAppRegistrationRuntimes(ctx context.Context) error {
if s == nil || s.store == nil || s.connectionSecrets == nil {
return errors.New("GitHub App registration dependencies are not configured")
}
repository := NewAppRegistrationRepository(s.store, s.connectionSecrets)
registrations, err := s.store.ListAppRegistrations(ctx)
if err != nil {
return err
}
var loadErrors []error
for _, registration := range registrations {
if registration == nil {
continue
}
resolved, resolveErr := ResolveAppRegistrationConfig(ctx, registration.ID, repository)
if resolveErr == nil {
resolveErr = s.ApplyAppRegistrationRuntime(resolved)
}
if resolveErr != nil {
loadErrors = append(loadErrors, fmt.Errorf("load GitHub App registration %s: %w", registration.ID, resolveErr))
}
}
if cleanupErr := repository.CleanupOrphanedCredentialBundles(ctx); cleanupErr != nil {
loadErrors = append(loadErrors, cleanupErr)
}
return errors.Join(loadErrors...)
}
func (s *Service) buildDeploymentAppRuntime(
resolved ResolvedDeploymentAppConfig,
) (*githubAppRuntime, error) {
if s.store == nil || s.connectionSecrets == nil {
return nil, errors.New("GitHub App authentication dependencies are not configured")
}
if resolved.Source != DeploymentAppSourceManaged {
return nil, errors.New("GitHub App runtime source is invalid")
}
if resolved.Registration == nil || strings.TrimSpace(resolved.Registration.ID) == "" {
return nil, errors.New("GitHub App registration ID is required")
}
if resolved.Registration.Status != AppRegistrationStatusActive {
return nil, errors.New("GitHub App registration is not active")
}
if err := resolved.Config.Validate(); err != nil {
return nil, fmt.Errorf("invalid GitHub App runtime configuration: %w", err)
}
privateKey, err := resolved.Config.PrivateKeyPEM()
if err != nil {
return nil, fmt.Errorf("load GitHub App private key: %w", err)
}
appClient, err := NewAppClient(resolved.Config.AppID, privateKey)
if err != nil {
return nil, fmt.Errorf("initialize GitHub App client: %w", err)
}
return s.buildDeploymentAppRuntimeWithClient(resolved, appClient)
}
func (s *Service) buildDeploymentAppRuntimeWithClient(
resolved ResolvedDeploymentAppConfig,
appClient *AppClient,
) (*githubAppRuntime, error) {
config := resolved.Config
baseURL := strings.TrimRight(config.PublicBaseURL, "/")
if appClient == nil || baseURL == "" || config.ClientID == "" || config.ClientSecret == "" ||
config.WebhookSecret == "" || config.Slug == "" {
return nil, errors.New("GitHub App runtime configuration is incomplete")
}
flows := NewOAuthFlowManager(s.store)
oauth := NewGitHubOAuthClient(config.ClientID, config.ClientSecret)
personalRepo := s.personalConnections
if personalRepo == nil {
personalRepo = NewStorePersonalConnectionRepository(s.store, s.connectionSecrets)
}
connectionStore := &serviceAppConnectionStore{service: s}
personal := NewPersonalAuthService(PersonalAuthConfig{
RegistrationID: resolved.Registration.ID,
ClientID: config.ClientID,
CallbackURL: baseURL + "/api/v1/github/app/registrations/" + resolved.Registration.ID + "/personal/callback",
}, flows, personalRepo, oauth)
personal.SetWorkspaceMutationLock(s.workspaceConnectionMutationLock)
registrationID := resolved.Registration.ID
generation := resolved.Registration.CredentialGeneration
tokens := NewAppInstallationTokenCache(registrationID, generation, appClient)
return &githubAppRuntime{
registrationID: registrationID, source: resolved.Source, generation: generation, appID: config.AppID,
appClient: appClient, installationTokens: tokens,
installationProvider: NewCachedInstallationCredentialProvider(tokens),
appInstallationAuth: NewAppInstallationService(AppInstallationConfig{
RegistrationID: registrationID, Slug: config.Slug,
CallbackURL: baseURL + "/api/v1/github/app/registrations/" + registrationID + "/install/callback",
}, flows, connectionStore, appClient, oauth),
personalAuth: personal,
webhookAuth: NewAppRegistrationWebhookService(
registrationID, config.WebhookSecret, connectionStore,
&installationRepositorySettingsUpdater{service: s}, personalRepo, s.eventBus,
GitHubWebhookReconciliation{Installations: appClient, Personal: personal},
),
}, nil
}
func (s *Service) InitializeAppRegistrationLifecycle() error {
if s == nil || s.store == nil || s.connectionSecrets == nil {
return errors.New("GitHub App registration dependencies are not configured")
}
repository := NewAppRegistrationRepository(s.store, s.connectionSecrets)
s.mu.Lock()
s.appRegistrationLifecycle = NewAppRegistrationLifecycleService(AppRegistrationLifecycleConfig{
Repository: repository, Store: s.store, Runtime: s,
Converter: NewManifestConversionClient(),
})
s.mu.Unlock()
return nil
}
func (s *Service) currentAppRegistrationLifecycle() *AppRegistrationLifecycleService {
if s == nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
return s.appRegistrationLifecycle
}
func (s *Service) ListAppRegistrationCatalog(
ctx context.Context,
userID, workspaceID string,
) (AppRegistrationCatalog, error) {
if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil {
return AppRegistrationCatalog{}, err
}
lifecycle := s.currentAppRegistrationLifecycle()
if lifecycle == nil {
return AppRegistrationCatalog{}, ErrGitHubNotConfigured
}
return lifecycle.List(ctx, userID, workspaceID)
}
func (s *Service) StartAppRegistrationManifest(
ctx context.Context,
userID string,
request AppRegistrationManifestStartRequest,
) (AppRegistrationManifestStart, error) {
if err := s.authorizeWorkspaceAccess(ctx, request.WorkspaceID); err != nil {
return AppRegistrationManifestStart{}, err
}
lifecycle := s.currentAppRegistrationLifecycle()
if lifecycle == nil {
return AppRegistrationManifestStart{}, ErrGitHubNotConfigured
}
return lifecycle.StartManifest(ctx, userID, request)
}
func (s *Service) CompleteAppRegistrationManifest(
ctx context.Context,
registrationID string,
callback AppRegistrationManifestCallback,
) (AppRegistrationManifestResult, error) {
lifecycle := s.currentAppRegistrationLifecycle()
if lifecycle == nil {
return AppRegistrationManifestResult{}, ErrGitHubNotConfigured
}
return lifecycle.CompleteManifest(ctx, registrationID, callback)
}
func (s *Service) ImportAppRegistration(
ctx context.Context,
userID string,
request AppRegistrationImportRequest,
) (*AppRegistration, error) {
if err := s.authorizeWorkspaceAccess(ctx, request.WorkspaceID); err != nil {
return nil, err
}
lifecycle := s.currentAppRegistrationLifecycle()
if lifecycle == nil {
return nil, ErrGitHubNotConfigured
}
return lifecycle.Import(ctx, userID, request)
}
func (s *Service) PrepareAppRegistrationImport(
ctx context.Context,
userID string,
request AppRegistrationImportPrepareRequest,
) (AppRegistrationImportPreparationResult, error) {
if err := s.authorizeWorkspaceAccess(ctx, request.WorkspaceID); err != nil {
return AppRegistrationImportPreparationResult{}, err
}
lifecycle := s.currentAppRegistrationLifecycle()
if lifecycle == nil {
return AppRegistrationImportPreparationResult{}, ErrGitHubNotConfigured
}
return lifecycle.PrepareImport(ctx, userID, request)
}
func (s *Service) RenameAppRegistration(
ctx context.Context,
userID, registrationID, displayName string,
) (*AppRegistration, error) {
lifecycle := s.currentAppRegistrationLifecycle()
if lifecycle == nil {
return nil, ErrGitHubNotConfigured
}
return lifecycle.Rename(ctx, userID, registrationID, displayName)
}
func (s *Service) DeleteAppRegistration(
ctx context.Context,
userID, registrationID string,
) error {
lifecycle := s.currentAppRegistrationLifecycle()
if lifecycle == nil {
return ErrGitHubNotConfigured
}
return lifecycle.Delete(ctx, userID, registrationID)
}
func (s *Service) ResetAppRegistrationsForE2E(ctx context.Context, workspaceID string) error {
lifecycle := s.currentAppRegistrationLifecycle()
if lifecycle == nil {
return nil
}
return lifecycle.ResetForE2E(ctx, workspaceID)
}
type runtimeInstallationCredentialProvider struct{ service *Service }
func (p *runtimeInstallationCredentialProvider) ResolveInstallation(
ctx context.Context,
connection *WorkspaceConnection,
req ResolveCredentialRequest,
) (*ResolvedCredential, error) {
if connection == nil || strings.TrimSpace(connection.AppRegistrationID) == "" {
return nil, ErrGitHubNotConfigured
}
runtime := p.service.currentAppRegistrationRuntime(connection.AppRegistrationID)
if runtime == nil || runtime.installationProvider == nil {
return nil, ErrGitHubNotConfigured
}
return runtime.installationProvider.ResolveInstallation(ctx, connection, req)
}
func (p *runtimeInstallationCredentialProvider) AppCredentialGeneration(
registrationID string,
) (int64, bool) {
runtime := p.service.currentAppRegistrationRuntime(registrationID)
if runtime == nil {
return 0, false
}
return runtime.generation, true
}
type runtimeUserCredentialProvider struct{ service *Service }
func (p *runtimeUserCredentialProvider) ResolveUser(
ctx context.Context,
connection *UserConnection,
req ResolveCredentialRequest,
) (*ResolvedCredential, error) {
if connection == nil || strings.TrimSpace(connection.AppRegistrationID) == "" {
return nil, ErrGitHubPersonalRequired
}
runtime := p.service.currentAppRegistrationRuntime(connection.AppRegistrationID)
if runtime == nil || runtime.personalAuth == nil {
return nil, ErrGitHubPersonalRequired
}
return (&personalAuthCredentialProvider{service: runtime.personalAuth}).ResolveUser(ctx, connection, req)
}
func (s *Service) SetCredentialBroker(broker *CredentialBroker) {
s.mu.Lock()
defer s.mu.Unlock()
s.credentialBroker = broker
}
func (s *Service) CredentialBrokerReady() bool {
if s == nil {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
return s.credentialBroker != nil
}
func (s *Service) ConfigureCredentialBroker(authorizer BrokerScopeAuthorizer) error {
if s == nil || s.store == nil || s.resolver == nil || authorizer == nil {
return errors.New("GitHub credential broker dependencies are not configured")
}
s.SetCredentialBroker(NewCredentialBroker(s.store, s.resolver, authorizer))
return nil
}
func (s *Service) IssueGitHubCredentialLease(
ctx context.Context,
request CredentialLeaseRequest,
) (*CredentialLease, error) {
s.mu.Lock()
broker := s.credentialBroker
s.mu.Unlock()
if broker == nil {
return nil, ErrGitHubNotConfigured
}
return broker.Issue(ctx, request)
}
func (s *Service) ResolveGitHubCredential(
ctx context.Context,
request BrokerCredentialRequest,
) (*BrokerCredential, error) {
s.mu.Lock()
broker := s.credentialBroker
s.mu.Unlock()
if broker == nil {
return nil, ErrGitHubNotConfigured
}
return broker.Resolve(ctx, request)
}
type WorkspaceAutomationStatus struct {
*WorkspaceConnection
Actor *AuthPrincipal `json:"actor,omitempty"`
Capabilities map[GitHubAppCapability]bool `json:"capabilities,omitempty"`
MissingCapabilities []GitHubAppCapability `json:"missing_capabilities,omitempty"`
RateLimit *GitHubRateLimitInfo `json:"rate_limit,omitempty"`
LegacyMigration bool `json:"legacy_migration"`
}
type WorkspaceAuthStatus struct {
WorkspaceID string `json:"workspace_id"`
Automation *WorkspaceAutomationStatus `json:"automation,omitempty"`
Personal *UserConnection `json:"personal,omitempty"`
EffectivePersonalActor *AuthPrincipal `json:"effective_personal_actor,omitempty"`
EffectiveManualMutationActor *AuthPrincipal `json:"effective_manual_mutation_actor,omitempty"`
AppRegistration *AppRegistration `json:"app_registration,omitempty"`
GitHubAppAvailable bool `json:"github_app_available"`
AppAvailable bool `json:"app_available"`
Authenticated bool `json:"authenticated"`
Username string `json:"username"`
AuthMethod string `json:"auth_method"`
TokenConfigured bool `json:"token_configured"`
RequiredScopes []string `json:"required_scopes"`
RateLimit *GitHubRateLimitInfo `json:"rate_limit,omitempty"`
}
func (s *Service) GetWorkspaceAuthStatus(
ctx context.Context,
workspaceID, userID string,
) (*WorkspaceAuthStatus, error) {
if strings.TrimSpace(workspaceID) == "" {
return nil, ErrGitHubWorkspaceRequired
}
if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil {
return nil, err
}
if s == nil || s.store == nil {
return nil, ErrGitHubNotConfigured
}
connection, err := s.store.GetWorkspaceConnection(ctx, workspaceID)
if err != nil {
return nil, err
}
personal, err := s.workspacePersonalConnection(ctx, workspaceID, userID)
if err != nil {
return nil, err
}
status := &WorkspaceAuthStatus{
WorkspaceID: workspaceID, Personal: personal, RequiredScopes: RequiredGitHubScopes,
AuthMethod: AuthMethodNone,
}
if connection == nil {
return status, nil
}
status.AuthMethod = string(connection.Source)
status.TokenConfigured = connection.Source == ConnectionSourcePAT
status.Automation = &WorkspaceAutomationStatus{
WorkspaceConnection: connection,
LegacyMigration: connection.Source == ConnectionSourceLegacyShared,
}
if err := s.populateAppRegistrationStatus(ctx, connection, status); err != nil {
return nil, err
}
if connection.Status != ConnectionStatusActive || s.resolver == nil {
return status, nil
}
automation, resolveErr := s.resolver.Resolve(ctx, ResolveCredentialRequest{
WorkspaceID: workspaceID, Purpose: CredentialPurposeAutomation,
})
if resolveErr != nil || automation == nil {
return status, nil
}
status.Authenticated = true
status.Username = automation.Principal.Login
status.Automation.Actor = principalPointer(automation.Principal)
status.Automation.Capabilities = automation.Capabilities
status.Automation.MissingCapabilities = missingGitHubCapabilities(automation.Capabilities)
status.Automation.RateLimit = rateLimitInfoForTracker(automation.RateTracker)
status.RateLimit = status.Automation.RateLimit
s.populateEffectivePersonalActors(ctx, workspaceID, userID, status)
return status, nil
}
func (s *Service) workspacePersonalConnection(
ctx context.Context,
workspaceID, userID string,
) (*UserConnection, error) {
if userID == "" {
return nil, nil
}
return s.store.GetUserConnection(ctx, workspaceID, userID)
}
func (s *Service) populateAppRegistrationStatus(
ctx context.Context,
connection *WorkspaceConnection,
status *WorkspaceAuthStatus,
) error {
if connection.Source != ConnectionSourceGitHubAppInstallation || connection.AppRegistrationID == "" {
return nil
}
registration, err := s.store.GetAppRegistration(ctx, connection.AppRegistrationID)
if err != nil || registration == nil {
return err
}
status.AppRegistration = cloneDeploymentAppRegistration(registration)
runtime := s.AppRegistrationRuntimeSnapshot(registration.ID)
status.GitHubAppAvailable = registration.Status == AppRegistrationStatusActive && runtime.Ready
status.AppAvailable = status.GitHubAppAvailable
return nil
}
func (s *Service) populateEffectivePersonalActors(
ctx context.Context,
workspaceID, userID string,
status *WorkspaceAuthStatus,
) {
personalActor, _ := s.resolver.Resolve(ctx, ResolveCredentialRequest{
WorkspaceID: workspaceID, UserID: userID, Purpose: CredentialPurposePersonalRead,
})
if personalActor != nil {
status.EffectivePersonalActor = principalPointer(personalActor.Principal)
}
mutationActor, _ := s.resolver.Resolve(ctx, ResolveCredentialRequest{
WorkspaceID: workspaceID, UserID: userID, Purpose: CredentialPurposePersonalWrite,
})
if mutationActor != nil {
status.EffectiveManualMutationActor = principalPointer(mutationActor.Principal)
}
}
func principalPointer(principal AuthPrincipal) *AuthPrincipal {
copy := principal
return &copy
}
func missingGitHubCapabilities(capabilities map[GitHubAppCapability]bool) []GitHubAppCapability {
missing := make([]GitHubAppCapability, 0)
for _, capability := range allGitHubCapabilities {
if !capabilities[capability] {
missing = append(missing, capability)
}
}
return missing
}
func (s *Service) StartAppInstallation(
ctx context.Context,
workspaceID, userID, registrationID string,
) (AppInstallationStart, error) {
if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil {
return AppInstallationStart{}, err
}
runtime := s.currentAppRegistrationRuntime(registrationID)
if runtime == nil || runtime.appInstallationAuth == nil {
return AppInstallationStart{}, ErrGitHubNotConfigured
}
return runtime.appInstallationAuth.Start(ctx, workspaceID, userID)
}
func (s *Service) CompleteAppInstallation(
ctx context.Context,
registrationID string,
callback AppInstallationCallback,
) (AppInstallationResult, error) {
runtime := s.currentAppRegistrationRuntime(registrationID)
if runtime == nil || runtime.appInstallationAuth == nil {
return AppInstallationResult{}, ErrGitHubNotConfigured
}
return runtime.appInstallationAuth.Complete(ctx, callback)
}
func (s *Service) StartPersonalAuth(
ctx context.Context,
workspaceID, userID string,
) (PersonalAuthStart, error) {
if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil {
return PersonalAuthStart{}, err
}
connection, err := s.store.GetWorkspaceConnection(ctx, workspaceID)
if err != nil {
return PersonalAuthStart{}, err
}
if connection == nil || connection.Source != ConnectionSourceGitHubAppInstallation ||
strings.TrimSpace(connection.AppRegistrationID) == "" {
return PersonalAuthStart{}, ErrGitHubPersonalRequired
}
runtime := s.currentAppRegistrationRuntime(connection.AppRegistrationID)
if runtime == nil || runtime.personalAuth == nil {
return PersonalAuthStart{}, ErrGitHubNotConfigured
}
return runtime.personalAuth.Start(ctx, workspaceID, userID)
}
func (s *Service) CompletePersonalAuth(
ctx context.Context,
registrationID string,
callback PersonalAuthCallback,
) (PersonalAuthResult, error) {
runtime := s.currentAppRegistrationRuntime(registrationID)
if runtime == nil || runtime.personalAuth == nil {
return PersonalAuthResult{}, ErrGitHubNotConfigured
}
return runtime.personalAuth.Complete(ctx, callback)
}
func (s *Service) DisconnectPersonalAuth(ctx context.Context, workspaceID, userID string) error {
if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil {
return err
}
if s == nil || s.personalConnections == nil {
return nil
}
return s.personalConnections.RevokePersonalConnection(ctx, workspaceID, userID)
}