package migrationconfig // import "git.griefed.de/griefed/repoman/internal/migrationconfig" Package migrationconfig defines the domain model for repoman's per-namespace migration configurations. A MigrationConfig is the unit that: - has a single Forgejo target owner (where imports land) - has one or more Source namespaces (where imports come from) - has its own cron schedule (when runs fire) - owns a set of repositories (which repos belong to this config) Each save creates a new MigrationConfigVersion for audit history. Conflict analysis lives in conflicts.go and is invoked by the service layer before persisting changes that may overlap with existing configurations. VARIABLES var ErrNameTaken = errors.New("migration config name already taken") ErrNameTaken is returned when an attempt is made to create or rename a config to a name that another config already uses. var ErrNotFound = errors.New("migration config not found") ErrNotFound is returned when a config is not found by lookup. Callers compare with errors.Is. var SupportedPlatforms = []string{ "github", "gitlab", "gitea", "codeberg", } SupportedPlatforms is the canonical set of source platforms. Adding a new platform here is necessary but not sufficient — the migration engine also needs to know how to talk to it. Each platform has a default URL (PlatformDefaultURL) that a Source may override — so self-hosted GitLab/Gitea is "<platform> + InstanceURL", not a separate platform. FUNCTIONS func FormatID(id int64) string FormatID is a tiny helper for callers that want a string ID for audit-log resource references. func IsSupportedPlatform(platform string) bool IsSupportedPlatform reports whether the given platform identifier is one we can actually import from. func LookupOwnerToken(ownerTokens map[string]string, platform, instanceURL, namespace string) string LookupOwnerToken finds the owner's account token for a source's platform, instance and namespace: the token stored for that exact namespace when there is one, otherwise the platform+instance wildcard. The precedence is the whole point of namespace scoping. A token narrowed to one org must not be handed to a source in a different org, while a token that names no namespace stays usable everywhere on its instance — which is what every token migrated from before namespaces became part of a token's identity looks like. Returns "" when neither is present. func NormalizeInstanceURL(platform, instanceURL string) string NormalizeInstanceURL canonicalizes a source instance URL so the same instance always maps to the same account-token key. It trims spaces and a trailing slash and returns "" — the canonical "default (cloud) URL" key — when the URL is blank or equal (case-insensitively) to the platform's default URL. So a source with no override and one explicitly set to "https://gitlab.com/" both resolve to the cloud GitLab token, while "https://gitlab.corp.example" is its own key. func OwnerTokenKey(platform, instanceURL, namespace string) string OwnerTokenKey is the lookup key for a config owner's account token map (OwnerTokens): the platform, its normalized instance URL and the namespace the token belongs to, joined by a NUL that can't appear in any part. Both the map builder (db.UserPlatformTokenRepo) and every resolver must key through this helper so a token stored for (platform, instance, namespace) is found by a source with the same three. A namespace of "" is the WILDCARD key: "any namespace on this platform and instance". Every token predating namespace scoping is stored that way, so a single PAT covering several orgs keeps resolving for all of them. Use LookupOwnerToken rather than this helper directly — it applies the exact-then- wildcard precedence that makes the wildcard work. func PlatformDefaultURL(platform string) string PlatformDefaultURL returns the default web base URL for a platform, or "" for an unknown platform. Used for form placeholders and as the fallback when a Source provides no InstanceURL. TYPES type AnalyzerDeps struct { Configs ConfigReader Repos RepoReader } AnalyzerDeps bundles what the analyzer needs to read. We pass a struct rather than separate args so adding a new dependency in future doesn't churn every call site. type ConfigReader interface { List(ctx context.Context) ([]*MigrationConfig, error) } ConfigReader is the read-only subset of the config repository the analyzer needs. Tests substitute an in-memory fake. type ConflictAnalysis struct { OwnedConflicts []OwnedConflict UnownedExisting []ExistingRepo SourceOverlaps []SourceOverlap } ConflictAnalysis is the result of analysing a candidate config against the existing state of repoman. The UI presents these three sections (Option C) before the operator commits the save: - OwnedConflicts: Forgejo repos that already belong to another config and would normally be claimed by this one. Operator chooses per-repo: keep with old / move to new. - UnownedExisting: Forgejo repos in the target owner that have no owning config (legacy or admin-orphaned). Operator chooses per-repo: assign to this config / leave unowned. - SourceOverlaps: Other configs that read from the same source (platform + namespace) as the candidate. SOFT info — only blocking when they ALSO target the same Forgejo owner. func Analyze( ctx context.Context, deps AnalyzerDeps, candidate *MigrationConfig, excludeID *int64, ) (*ConflictAnalysis, error) Analyze examines the candidate config against the existing world and returns the three conflict categories. The candidate need NOT be persisted yet — analyze runs purely on read data plus the in-memory candidate. excludeID, when non-nil, omits that ID from comparisons. Used during EDIT: the candidate IS one of the existing configs, and we don't want to flag it conflicting with itself. func (a *ConflictAnalysis) HasBlockers() bool HasBlockers reports whether the analysis contains issues that prevent saving entirely (as opposed to per-repo decisions). Currently: a SourceOverlap with the same Forgejo target owner is a blocker because it would mean two configs writing the same repos to the same place — pointless and race-prone. type CreateInput struct { Config *MigrationConfig Comment string UserID *int64 Decisions map[int64]Decision // empty = analyze first, no decisions yet OverrideBlockers bool // true means: the operator saw blockers and chose to proceed anyway } CreateInput is the payload for Service.Create. Decisions maps a repository_id to the operator's choice for that repo, sourced from the conflict-resolution UI. type Decision string Decision is the operator's per-repo choice rendered by the UI. Used by the Save path to know what to do with each conflict. const ( // DecisionClaim assigns the repo to the candidate config. DecisionClaim Decision = "claim" // DecisionLeave leaves the repo with its current owner (or // unowned, in the UnownedExisting case). DecisionLeave Decision = "leave" ) type ExistingRepo struct { RepositoryID int64 ForgejoOwner string ForgejoName string } ExistingRepo is a Forgejo repo in the target owner that has no owning config (owning_migration_config_id IS NULL). type MigrationConfig struct { ID int64 Name string ForgejoTargetOwner string ForgejoTargetOwnerType OwnerType NamespaceConfirmedAt *time.Time Sources []Source TokenOverrides map[string]string // platform → token Settings Settings CronExpression string // empty = manual-only IsEnabled bool LastRunAt *time.Time NextRunAt *time.Time CreatedAt time.Time UpdatedAt time.Time CreatedByUserID *int64 LastModifiedByUserID *int64 // OwnerUserID is the user who may manage this config and whose personal // tokens its runs authenticate with (multi-tenant Phase B). Nil for a legacy // config with no resolved owner — an admin must claim it. Set from the creator // on create; admins may reassign. OwnerUserID *int64 } MigrationConfig mirrors the migration_configs row. All time fields are time.Time at the Go boundary; the repository implementation handles TEXT conversion. Slice and map fields are the in-memory shape of the JSON columns documented in the schema. func ParseSnapshot(snapshotJSON string) (*MigrationConfig, error) ParseSnapshot unmarshals a version's snapshot_json back into a MigrationConfig. Token material is already redacted from the stored snapshot, so the result carries no secrets — used by the history view to diff successive versions. An empty string yields a zero-value config. func (c *MigrationConfig) ActiveOutboundTargets() []string ActiveOutboundTargets returns the source platforms this config actually pushes back to: none when ImportOnly is set, otherwise the configured EnabledOutboundTargets. It is the single gate both engines consult so an import-only config can never sync, whatever stale targets a row may carry. func (c *MigrationConfig) EffectiveOwnerUserID() *int64 EffectiveOwnerUserID returns the user whose account this config's runs authenticate as and who may manage it: the explicit OwnerUserID when set, else the original CreatedByUserID, else nil (a legacy/CLI-created config no one has claimed). Token resolution and owner-routing key on THIS, not the immutable creator alone, so an admin reassigning the owner actually moves credential lookup with it. Nil-safe receiver. func (c *MigrationConfig) ImportsAsPullMirror() bool ImportsAsPullMirror reports whether newly imported repos should be created as Forgejo pull mirrors. True when the operator opted in via MirrorOnImport, OR when outbound sync is active — an outbound config relays source→Forgejo→ targets, so the Forgejo copy must itself track the source. It is the single gate the engine consults so the two reasons to pull-mirror never diverge. func (c *MigrationConfig) IsConfirmed() bool IsConfirmed reports whether the operator has explicitly confirmed the namespace pinning for this config. Until confirmed, real runs must redirect through the confirmation flow. func (c *MigrationConfig) IsOwnedBy(userID int64) bool IsOwnedBy reports whether userID owns this config. Nil-safe; a config with no owner (legacy) is owned by nobody. func (c *MigrationConfig) ResolveSourceToken(src Source, ownerFallback string) string ResolveSourceToken picks the access token for one INBOUND source, in precedence order: the per-source token → the supplied owner fallback (the config owner's account token for this source's platform+instance+namespace, see OwnerTokenKey). The owner fallback is the reference model's primary source of token VALUES; Source.Token is a legacy slot that only carries a value on a config saved before the reference model and not yet re-saved (a save routes it into the account store and blanks it). It is the single resolution rule shared by the migrate engine and the issue-sync webhook ingress, so the two can never authenticate differently. Nil-safe receiver. TokenOverrides is deliberately NOT consulted here. It is the OUTBOUND per-config credential (resolveOutboundToken) and the config form presents it as such — "tokens this config uses to push repositories back out". Reading it for inbound made a write-scoped outbound token silently outrank the account token for every source on that platform, so a run failed with an invalid-token error while the source row's "Test connection" — which resolves the account token directly — reported success. It also could not be keyed correctly for inbound: TokenOverrides is keyed by platform ALONE, with no instance URL and no namespace, so one entry applied to two GitHub orgs and to github.com plus a GitHub Enterprise host alike. Don't put it back. func (c *MigrationConfig) Validate() error Validate checks the config for internal consistency. Called by the service layer before persisting any change. Returns the first problem encountered; the caller should surface it to the operator. Rules: - Name not empty, no whitespace-only - ForgejoTargetOwner not empty - ForgejoTargetOwnerType is a valid OwnerType - Sources: each platform is supported, each Source has a non-empty namespace, each OwnerType is valid. The same platform MAY appear more than once (each source has its own URL override and token); the GoRunner iterates sources independently. - Settings.EnabledOutboundTargets entries are supported platforms type OwnedConflict struct { RepositoryID int64 ForgejoOwner string ForgejoName string OwningConfigID int64 OwningConfigName string } OwnedConflict is a Forgejo repo that already belongs to another migration config and would be claimed by the candidate. type OwnerType string OwnerType is "user" or "org" — both for Forgejo targets and source namespaces. Validated at the DB CHECK constraint level too. const ( OwnerUser OwnerType = "user" OwnerOrg OwnerType = "org" ) func (o OwnerType) Valid() bool Valid reports whether the OwnerType is one of the accepted values. type OwnershipWriter interface { AssignOwnership(ctx context.Context, repositoryID int64, configID *int64) error } OwnershipWriter is the small write surface needed when the operator applies per-repo Decisions. Implementation lives in internal/db. AssignOwnership sets owning_migration_config_id for the given repo; pass nil to unassign. The interface is decoupled from the full repository repository (which doesn't exist yet — the migration engine will build it) so the service only sees what it needs. type RepoReader interface { // ListByForgejoOwner returns repos in the given Forgejo owner. // Used to detect both OwnedConflicts and UnownedExisting. ListByForgejoOwner(ctx context.Context, owner string) ([]*RepoSummary, error) } RepoReader is the read-only subset of the repositories repository the analyzer needs. Defined here (rather than imported from internal/db) to avoid an import cycle and to keep the analyzer independent of any specific persistence layer. The methods correspond to one-line SQL queries; implementations are trivial. type RepoSummary struct { ID int64 ForgejoOwner string ForgejoName string OwningMigrationConfigID *int64 } RepoSummary is the minimum a RepoReader returns. Not the full row — the analyzer only needs identity and current owner. type Repository interface { // Create inserts a new config and returns the assigned ID. The // caller has already called Validate. Create(ctx context.Context, config *MigrationConfig) (int64, error) // Update replaces the row identified by config.ID. Implementations // also write a snapshot of the PRE-UPDATE state into // migration_config_versions so the history is preserved. Update(ctx context.Context, config *MigrationConfig, comment string, modifierUserID *int64) error // GetByID returns the config or ErrNotFound. GetByID(ctx context.Context, id int64) (*MigrationConfig, error) // GetByName is the lookup the CLI uses (`repoman run --config NAME`). GetByName(ctx context.Context, name string) (*MigrationConfig, error) // List returns all configs, ordered by name. No pagination — the // expected count is small (up to a few dozen). List(ctx context.Context) ([]*MigrationConfig, error) // Delete removes a config. Repositories that pointed at it have // owning_migration_config_id set to NULL via ON DELETE SET NULL. Delete(ctx context.Context, id int64) error // MarkConfirmed sets namespace_confirmed_at to `at`. Called when // the operator passes through the first-run confirmation flow. MarkConfirmed(ctx context.Context, id int64, at time.Time) error // UpdateScheduleTimestamps records last_run_at and next_run_at. // Called by the scheduler after each run completes / is queued. UpdateScheduleTimestamps(ctx context.Context, id int64, lastRun, nextRun *time.Time) error } Repository is the persistence interface this package depends on. Implementations live in internal/db; tests substitute an in-memory fake. type SaveResult struct { ConfigID int64 Conflicts *ConflictAnalysis } SaveResult is what the service returns after a Create/Update. Conflicts is non-nil if the analyzer found overlaps the operator must resolve; in that case the config was NOT persisted and the handler should re-render the form with the conflict list. On the success path Conflicts is nil and ConfigID is the persisted row id. type Service struct { Configs Repository Repos RepoReader Owners OwnershipWriter Tx TxRunner Now func() time.Time } Service is the application-layer orchestrator for migration configurations. It composes: - Repository — durable storage of configs and their versions - ConflictAnalyzer — detects overlaps with existing state - per-repo Decisions — the operator's choice for each conflicting repo HTTP handlers and CLI commands depend on Service rather than on Repository directly, so the conflict-resolution flow is consistent across both interfaces. func NewService(configs Repository, repos RepoReader, owners OwnershipWriter) *Service NewService constructs a Service with sensible defaults. The Tx field defaults to a non-transactional pass-through (sound only for in-memory fakes); callers backed by a real database must override it with db.ConfigTxRunner so config + ownership writes are atomic. func (s *Service) Confirm(ctx context.Context, configID int64) error Confirm marks a config's namespace as confirmed by the operator. Called from the first-run-confirm page after a real (non-dry) run is requested for a config whose namespace_confirmed_at is NULL. The actual run still has to be triggered separately; Confirm only flips the bit so the next trigger doesn't bounce back to the confirmation page. func (s *Service) Create(ctx context.Context, in CreateInput) (*SaveResult, error) Create validates the candidate, runs the conflict analyzer, and either persists (when Decisions resolve every owned-conflict) or returns the analysis for the operator to act on. Flow: 1. Validate the config syntactically. Failure → error, no persistence. 2. Analyze conflicts. 3. If blockers present and !OverrideBlockers → return analysis, not persisted. 4. If OwnedConflicts exist that have no Decision in input → return analysis, not persisted. Operator must choose for every conflict. 5. Otherwise persist the config, then apply Decisions atomically: - DecisionClaim → AssignOwnership(repoID, &newConfigID) - DecisionLeave → no change (OwnedConflict) OR no change (UnownedExisting) Note on UnownedExisting: per Option C the operator may choose to claim or leave each. Decisions for UnownedExisting follow the same schema. A missing Decision for an UnownedExisting defaults to leave (do nothing) — this is the conservative default that does not surprise the operator. func (s *Service) Delete(ctx context.Context, id int64) error Delete removes a config. ON DELETE SET NULL on repositories. owning_migration_config_id means previously-owned repos become unowned (visible in inventory but no longer managed). func (s *Service) Get(ctx context.Context, id int64) (*MigrationConfig, error) Get is a thin pass-through, included so handlers don't need a separate dependency on Repository for read paths. func (s *Service) GetByName(ctx context.Context, name string) (*MigrationConfig, error) GetByName same. func (s *Service) List(ctx context.Context) ([]*MigrationConfig, error) List same. func (s *Service) Update(ctx context.Context, in UpdateInput) (*SaveResult, error) Update is the edit-flow analogue of Create. It runs the analyzer with excludeID set to the config's own ID so self-overlap doesn't count, then either applies the change or returns the analysis. type Settings struct { // ForcePrivacyOverrideOnTargets enforces the SOURCE's visibility on an // outbound target that already exists, on every run, instead of only carrying // it over when repoman first creates the repo there // (ensureTargetRepo → RepoSpec.Private). Both engines honour it: the GoRunner // passes source.RepoMetadata.Private through the metadata sync, and BuildArgs // exports it for the bash ScriptRunner. // // Default OFF, and deliberately so: it changes who can SEE a repository on // another platform, so a target deliberately made public or private on the // mirror keeps that until an operator opts in. Every enforcement is logged. // // It also propagates an archived FORGEJO repo to every target — marking each // one archived and taking down its push mirror (migrate.standDownArchivedOutbound). // Forgejo is the trigger, not the source: once the hub's copy is archived // nothing new is committed or released there, so freezing the mirrors costs no // future sync. A SOURCE can be archived while the hub is still live and // collecting releases from CI, which is why that flag must not drive it. The // stand-down runs last in a repo's outbound sequence so the final releases land // before a target turns read-only, and it never UNarchives. ForcePrivacyOverrideOnTargets bool `json:"force_privacy_override_on_targets,omitempty"` ForceArchiveOverrideOnTargets bool `json:"force_archive_override_on_targets,omitempty"` // PreserveNamespace controls whether sub-org structure is preserved // on import. Defaults to true; set false to flatten everything to // the target owner's top level. PreserveNamespace bool `json:"preserve_namespace,omitempty"` // ImportOnly, when true, makes the config import to Forgejo ONLY and push // back to no source platform — regardless of EnabledOutboundTargets. It is // the safe default for a new config; turn it off to opt into selective // outbound sync. See MigrationConfig.ActiveOutboundTargets. ImportOnly bool `json:"import_only,omitempty"` // EnabledOutboundTargets is the list of source platforms this config also // pushes back to (push mirrors), so a user can keep selected source copies // in sync long-term. Empty = no outbound sync. Each entry must be a // configured source platform. Ignored entirely when ImportOnly is true. EnabledOutboundTargets []string `json:"enabled_outbound_targets,omitempty"` // MirrorOnImport, when true, creates each newly imported repo as a Forgejo // PULL mirror of its source, so Forgejo re-pulls from the source on an // interval and the destination stays current without re-runs. It only takes // effect at first import — Forgejo cannot convert an already-existing repo // into a mirror — so a repo already present in the target is still skipped. // Independent of outbound push mirrors; see ImportsAsPullMirror. MirrorOnImport bool `json:"mirror_on_import,omitempty"` // DryRun makes the run preview-only — no writes to Forgejo or to // outbound targets. The corresponding `repoman run --dry-run` // CLI flag overrides this on a per-invocation basis. DryRun bool `json:"dry_run,omitempty"` // SyncIssues opts this config into the issue hub: during a (non-dry) run, // each imported/already-present repo has its source issues + comments // imported into the Forgejo hub and kept in sync. Off by default; see // docs/issue-hub.md and internal/issuesync. SyncIssues bool `json:"sync_issues,omitempty"` } Settings holds per-config knobs that today live in the bash script's .env file. We split them out as a typed struct (rather than an untyped map) so the UI form has a stable shape and so adding a new setting requires touching exactly one spot. All boolean fields default to false; all string fields default to empty. Migrate-engine code reads zero values as "off / not set". type Source struct { Platform string `json:"platform"` // "github", "gitlab", "gitea", "codeberg" Namespace string `json:"namespace"` // owner name on that platform OwnerType OwnerType `json:"owner_type"` // user | org InstanceURL string `json:"instance_url,omitempty"` // URL override; empty = platform default (PlatformDefaultURL) Token string `json:"token,omitempty"` // per-source token; empty = fall back to per-platform override then global. SECRET — sources_json is encrypted at rest. } Source is one entry in MigrationConfig.Sources — a (platform, namespace) pair the config reads from. The same platform may appear more than once (e.g. two GitHub orgs with different tokens); each source carries its own optional URL override and token. type SourceOverlap struct { OtherConfigID int64 OtherConfigName string Platform string Namespace string SameForgejoTarget bool } SourceOverlap describes another config that reads from the same source (platform, namespace) as the candidate. SameForgejoTarget=true means both configs also write to the same Forgejo target owner — the dangerous case. SameForgejoTarget=false means the same source feeds different targets, which is legitimate (e.g. one config archives to a backup org). type Tx struct { Configs Repository Owners OwnershipWriter } Tx bundles the repositories that must commit together when a config is saved: the config row itself (Configs) and the ownership reassignments the operator's Decisions imply (Owners). A TxRunner hands the service a Tx whose two repos share one database transaction so a partial save is impossible (Bug #2). type TxRunner interface { RunInTx(ctx context.Context, fn func(Tx) error) error } TxRunner runs fn inside a single transaction, committing when fn returns nil and rolling back otherwise. The production implementation lives in internal/db (ConfigTxRunner); tests supply an in-memory fake with snapshot/restore semantics. type UpdateInput struct { Config *MigrationConfig Comment string UserID *int64 Decisions map[int64]Decision OverrideBlockers bool } UpdateInput mirrors CreateInput for the edit flow. The Config.ID must match an existing row; otherwise Update returns ErrNotFound. type Version struct { ID int64 MigrationConfigID int64 SnapshotJSON string Comment string CreatedAt time.Time CreatedByUserID *int64 } Version is one historical snapshot of a MigrationConfig. The SnapshotJSON contains the entire config row at the time of the save, serialised by the Repository's Update method. type VersionRepository interface { // Create inserts a snapshot. Typically called by the Repository // implementation as part of Update; exposed here so direct callers // (e.g. import-from-bash migration) can record initial versions. Create(ctx context.Context, configID int64, snapshotJSON, comment string, userID *int64, at time.Time) (int64, error) // ListForConfig returns all versions for a given config, newest first. ListForConfig(ctx context.Context, configID int64, limit int) ([]*Version, error) // GetByID returns one version snapshot. GetByID(ctx context.Context, id int64) (*Version, error) } VersionRepository persists the per-config version history. Created as a separate interface from Repository because version reads are independent of config reads and the methods don't need to share a transaction in either direction.