// Package workflowsync keeps workspace workflows in sync with definition // files stored in a configured GitHub repository. A background poller fetches // the configured directory on an interval and applies changes through the // workflow service; users can also force a sync from the settings UI. Synced // workflows coexist with manually-created ones — see workflows.source. package workflowsync import ( "errors" "fmt" "strings" "time" "github.com/kandev/kandev/internal/common/authcircuit" "github.com/kandev/kandev/internal/common/securityutil" ) // Defaults for optional config fields. const ( DefaultBranch = "main" DefaultPath = ".kandev/workflows" DefaultIntervalSeconds = 300 MinIntervalSeconds = 60 // MaxIntervalSeconds caps the poll interval at 30 days — far beyond any // sensible cadence, and safely below time.Duration overflow territory. MaxIntervalSeconds = 30 * 24 * 60 * 60 ) // Supported sync sources. A workspace has exactly one. const ( ProviderGitHub = "github" ProviderGitLab = "gitlab" ) // ErrInvalidConfig marks validation failures on SetConfigRequest so handlers // can map them to 400. var ErrInvalidConfig = errors.New("invalid workflow sync config") // ErrNotConfigured is returned when a sync is requested for a workspace that // has no workflow sync config. var ErrNotConfigured = errors.New("workflow sync is not configured for this workspace") // Config is the per-workspace workflow sync configuration plus the status of // the most recent sync attempt (written by the poller and force syncs). type Config struct { WorkspaceID string `json:"workspace_id"` // Provider selects the sync source: ProviderGitHub uses RepoOwner and // RepoName, ProviderGitLab uses ProjectPath. Provider string `json:"provider"` RepoOwner string `json:"repo_owner"` RepoName string `json:"repo_name"` ProjectPath string `json:"project_path"` Branch string `json:"branch"` Path string `json:"path"` IntervalSeconds int `json:"interval_seconds"` PollEnabled bool `json:"poll_enabled"` LastSyncedAt *time.Time `json:"last_synced_at,omitempty"` LastOk bool `json:"last_ok"` LastError string `json:"last_error,omitempty"` LastWarnings []string `json:"last_warnings,omitempty"` LastHash string `json:"-"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // FailureClass, ConsecutiveFailures, and NextRetryAt persist the // generation-aware circuit-breaker state for this config (see // internal/common/authcircuit). Exposed read-only to operators so a // permanently-failing config is visibly distinguishable from one that is // simply between polls. FailureClass authcircuit.FailureClass `json:"failure_class,omitempty"` ConsecutiveFailures int `json:"consecutive_failures,omitempty"` NextRetryAt *time.Time `json:"next_retry_at,omitempty"` // ConfigFingerprint and CredentialFingerprint are internal-only circuit // identity keys (never secrets — see authcircuit.State.Fingerprint) and // are not surfaced over the API. ConfigFingerprint string `json:"-"` CredentialFingerprint string `json:"-"` } // circuitState extracts the persisted authcircuit.State embedded in this // config's flat columns. func (c *Config) circuitState() authcircuit.State { return authcircuit.State{ FailureClass: c.FailureClass, ConsecutiveFailures: c.ConsecutiveFailures, NextRetryAt: c.NextRetryAt, Fingerprint: c.CredentialFingerprint, } } // applyCircuitState writes an updated authcircuit.State back onto the // config's flat columns (the in-memory mirror of what Store persists). func (c *Config) applyCircuitState(state authcircuit.State) { c.FailureClass = state.FailureClass c.ConsecutiveFailures = state.ConsecutiveFailures c.NextRetryAt = state.NextRetryAt c.CredentialFingerprint = state.Fingerprint } // circuitOpen reports whether this config's circuit is currently open (skip // syncing) at the given time. func (c *Config) circuitOpen(now time.Time) bool { return c.circuitState().Open(now) } // fingerprint is a stable, non-secret digest of the fields that define // "what this config points at". Changing any of these fields (a deliberate // SetConfig call) always resets the circuit — see Store.UpsertConfigForWorkspace. func (r *SetConfigRequest) fingerprint() string { return strings.Join([]string{ r.Provider, r.RepoOwner, r.RepoName, r.ProjectPath, r.Branch, r.Path, fmt.Sprintf("%d", r.IntervalSeconds), }, "\x1f") } // SetConfigRequest is the payload for creating or updating a workspace's // workflow sync config. Branch, Path, and IntervalSeconds fall back to // defaults when empty/zero. type SetConfigRequest struct { // Provider is optional; an empty value means ProviderGitHub, which keeps // pre-GitLab API clients working unchanged. Provider string `json:"provider"` RepoOwner string `json:"repo_owner"` RepoName string `json:"repo_name"` ProjectPath string `json:"project_path"` Branch string `json:"branch"` Path string `json:"path"` IntervalSeconds int `json:"interval_seconds"` // PollEnabled controls the background polling loop; nil defaults to // true. When false the workspace only syncs via "Sync now". PollEnabled *bool `json:"poll_enabled"` } // normalizeTarget validates the repository coordinates for the selected // provider and clears the other provider's fields, so a switch cannot leave // stale values behind. func (r *SetConfigRequest) normalizeTarget() error { r.Provider = strings.TrimSpace(r.Provider) if r.Provider == "" { r.Provider = ProviderGitHub } r.RepoOwner = strings.TrimSpace(r.RepoOwner) r.RepoName = strings.TrimSpace(r.RepoName) r.ProjectPath = strings.TrimSpace(r.ProjectPath) switch r.Provider { case ProviderGitHub: return r.normalizeGitHubTarget() case ProviderGitLab: return r.normalizeGitLabTarget() default: return fmt.Errorf("%w: provider must be %q or %q", ErrInvalidConfig, ProviderGitHub, ProviderGitLab) } } func (r *SetConfigRequest) normalizeGitHubTarget() error { if r.ProjectPath != "" { return fmt.Errorf("%w: project_path is only valid for the %q provider", ErrInvalidConfig, ProviderGitLab) } if r.RepoOwner == "" || strings.ContainsAny(r.RepoOwner, "/ ") { return fmt.Errorf("%w: repo_owner is required and cannot contain slashes or spaces", ErrInvalidConfig) } if r.RepoName == "" || strings.ContainsAny(r.RepoName, "/ ") { return fmt.Errorf("%w: repo_name is required and cannot contain slashes or spaces", ErrInvalidConfig) } return nil } // normalizeGitLabTarget requires a namespace path. GitLab projects live at // arbitrarily nested paths ("group/subgroup/project"), so the value is // validated as a whole rather than split into owner and name. func (r *SetConfigRequest) normalizeGitLabTarget() error { if r.RepoOwner != "" || r.RepoName != "" { return fmt.Errorf( "%w: repo_owner and repo_name are only valid for the %q provider", ErrInvalidConfig, ProviderGitHub) } if r.ProjectPath == "" { return fmt.Errorf("%w: project_path is required", ErrInvalidConfig) } if strings.Contains(r.ProjectPath, " ") { return fmt.Errorf("%w: project_path cannot contain spaces", ErrInvalidConfig) } segments := strings.Split(r.ProjectPath, "/") if len(segments) < 2 { return fmt.Errorf( "%w: project_path must include the namespace, e.g. \"group/project\"", ErrInvalidConfig) } for _, segment := range segments { if segment == "" { return fmt.Errorf( "%w: project_path cannot have empty segments or leading/trailing slashes", ErrInvalidConfig) } if segment == "." || segment == ".." { return fmt.Errorf("%w: project_path cannot contain \".\" or \"..\" segments", ErrInvalidConfig) } } return nil } // Normalize validates the request and fills defaults. It returns a wrapped // ErrInvalidConfig on bad input. func (r *SetConfigRequest) Normalize() error { r.Branch = strings.TrimSpace(r.Branch) r.Path = strings.Trim(strings.TrimSpace(r.Path), "/") if err := r.normalizeTarget(); err != nil { return err } if r.Branch == "" { r.Branch = DefaultBranch } if !securityutil.IsValidBranchName(r.Branch) { return fmt.Errorf("%w: branch is not a valid git branch name", ErrInvalidConfig) } if r.Path == "" { r.Path = DefaultPath } for _, segment := range strings.Split(r.Path, "/") { if segment == ".." { return fmt.Errorf("%w: path cannot contain \"..\"", ErrInvalidConfig) } } if r.IntervalSeconds == 0 { r.IntervalSeconds = DefaultIntervalSeconds } if r.IntervalSeconds < MinIntervalSeconds { return fmt.Errorf("%w: interval_seconds must be at least %d", ErrInvalidConfig, MinIntervalSeconds) } if r.IntervalSeconds > MaxIntervalSeconds { return fmt.Errorf("%w: interval_seconds must be at most %d", ErrInvalidConfig, MaxIntervalSeconds) } if r.PollEnabled == nil { enabled := true r.PollEnabled = &enabled } return nil } // SyncResult reports the outcome of one sync run for a workspace. type SyncResult struct { Created []string `json:"created"` Updated []string `json:"updated"` Deleted []string `json:"deleted"` Warnings []string `json:"warnings"` Unchanged bool `json:"unchanged"` }