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 BranchFilterMatches(filter, branch string) bool BranchFilterMatches reports whether a branch is one a filtered push mirror pushes. An empty filter matches everything, which is what Forgejo does with the `+refs/heads/*:refs/heads/*` refspec it configures in that case. Each entry becomes a git refspec verbatim (Forgejo builds "+refs/heads/<entry>:refs/heads/<entry>"), so the pattern language is git's, not the shell's: at most one "*", and it matches any run of characters INCLUDING "/". That is why this is not path.Match, whose "*" stops at a separator and would report "release/1.2" as unmatched by "release/*". It exists for the prune warning, which has to predict what a push will leave on the target: a branch the mirror does not push is one the mirror deletes. 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 NormalizeBranchFilter(filter string) string NormalizeBranchFilter puts a push-mirror branch filter into the one spelling repoman stores and compares: entries trimmed, empties dropped, joined by a single comma. It exists because the filter is compared, not just stored. Forgejo has no endpoint to UPDATE a push mirror, so changing a filter means deleting and re-creating the mirror — and the decision to do that is "does the filter Forgejo reports differ from the configured one?". Without a canonical form, "main, develop" typed in the form and "main,develop" read back from Forgejo would differ on every run and replace the mirror forever. The entries themselves are passed through untouched: they are Forgejo's own glob syntax (see Settings.OutboundBranchFilter) and repoman does not interpret them. 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: only when the operator opted in via MirrorOnImport AND the config pushes nothing back out. Outbound sync used to IMPLY a pull mirror, on the reasoning that an outbound config relays source→Forgejo→targets so the hub must track the source. That reasoning is now inverted by what the hub has to be able to receive. A Forgejo pull mirror is READ-ONLY to direct pushes ("mirror repository is read-only") and its next pull discards anything written anyway, so a mirrored hub cannot hold the UNION of every platform's refs — and the union is precisely what makes handing the outbound push to Forgejo's own push mirror safe, since a push mirror runs `git push -f --mirror` and prunes whatever the hub lacks. An outbound config therefore imports as an ordinary repository and repoman keeps it current itself; Validate refuses the contradictory combination rather than silently ignoring MirrorOnImport. Side benefit worth knowing: a one-time import also carries the wiki, labels, milestones and LFS that mirror mode gives up. 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) OutboundPolicyFor(target string) OutboundPolicy func (c *MigrationConfig) OutboundPolicyIsDefaulted(target string) bool OutboundPolicyFor returns the push policy configured for one outbound target, falling back to force+prune — the Forgejo push mirror — when the config names none. Every caller must come through here rather than indexing Settings.OutboundPolicies directly, because the map is nil on most configs and the FALLBACK is where the product decision lives. Indexing the map would hand back the Go zero value, which means the opposite. Why the fallback is the destructive pair: mirroring is safe once Forgejo holds the union of every platform's refs, repoman performs that union on every run before anything mirrors out, and the engine additionally refuses to install a mirror on a run whose union did not complete. A target an operator has never configured therefore gets the arrangement that needs no per-run git work from repoman and pushes as soon as the hub changes, rather than on a timer that would fire whether or not the union had run. OutboundPolicyIsDefaulted reports whether this target has NO stored policy and is therefore running on the force+prune default. It exists because the stored data cannot tell two very different operators apart. Before the default inverted, `parseOutboundPolicies` wrote nothing when both checkboxes were clear — so "never configured" and "deliberately chose the careful push" are the same absence on disk, and the inversion turns BOTH into a mirror that force-pushes and prunes. That is the intended upgrade for the first operator and a silent reversal for the second, and nothing in the config can separate them. The engine therefore says so out loud once per run rather than letting the second case discover it from a deleted branch. 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 OutboundPolicy struct { // AllowForce permits overwriting a ref the target holds at an object that is // not an ancestor of Forgejo's — i.e. rewriting history somebody else wrote. // Without it such a ref is reported and skipped, and every other ref still // goes. AllowForce bool `json:"allow_force"` // AllowPrune permits DELETING refs the target has and Forgejo does not. This // is the half that closed pull requests, and the half the union import exists // to make safe: once Forgejo holds every platform's refs there is nothing on // a target that the hub is missing. AllowPrune bool `json:"allow_prune"` } OutboundPolicy says what repoman may DESTROY on one outbound target while pushing Forgejo's refs to it. The two destructive acts are independent: force overwrites a ref the target moved on its own, prune deletes a ref the target has and Forgejo does not. THE ABSENCE OF AN ENTRY MEANS BOTH — see OutboundPolicyFor. That is the opposite of what this type meant when it was introduced, and the reason is the union import: repoman now merges every platform's branches, tags and notes into Forgejo BEFORE anything is mirrored out of it, so a mirror's `git push -f --mirror` replays a superset rather than deleting what the hub was missing. The ServerPackCreator incident that made the careful policy the default — Dependabot's branches lived only on GitHub, the mirror pruned them, and GitHub closed every pull request whose head branch had vanished — is addressed by removing its premise instead of by refusing to mirror. Two residual risks ride with that choice and are not fixed by the union: a branch the target holds at an unrelated commit is NOT imported (repoman must never rewrite Forgejo), and so is still overwritten outbound; and a ref created on the target after the union pass is pruned by the next sync. The zero VALUE is still "destroy nothing", and is what an operator selects to have repoman do the pushing itself. It must be stored explicitly to mean that — which is why neither field carries omitempty, and why the config form writes an entry for every enabled target rather than only the permissive ones. func (p OutboundPolicy) Describe() string Describe renders a policy for a run-log line or a change diff, naming what is PERMITTED rather than printing two booleans — an operator reading "force, prune" learns what the next sync may do to the target, "true true" does not. func (p OutboundPolicy) NeverDestroys() bool NeverDestroys reports whether this policy permits neither destructive half, so callers can tell a target repoman pushes carefully from one it may overwrite. It is NOT named IsDefault any more, and the rename is the point: an absent entry now yields force+prune, so "the default" and "the zero value" became opposite things and one name could not honestly mean both. func (p OutboundPolicy) UsesForgejoMirror() bool UsesForgejoMirror reports whether Forgejo's own push mirror expresses this policy, and therefore whether repoman may delegate the pushing to it. A Forgejo push mirror is `git push -f --mirror` and nothing else: it hardcodes `Force: true, Mirror: true` in services/mirror/mirror_push.go's performPush (verified in the v16.0.5 tag), independently of any branch filter. So it can express exactly ONE of the four policies — force AND prune — and for the other three repoman has to do the push itself (see gitsync.PushOutbound). Delegating anyway is what destroyed the branches this type exists to protect. 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"` // OutboundPolicies says, per outbound target, whether repoman may force-push // and whether it may prune — the two destructive halves of mirroring, each // opt-in and each independent. A target with no entry gets the zero // OutboundPolicy, which permits neither; read it through // MigrationConfig.OutboundPolicyFor and never by indexing this map. // // It also decides WHO does the pushing. Forgejo's push mirror is `git push -f // --mirror` and cannot be anything else, so it can serve exactly one of the // four policies; under the other three repoman pushes the refs itself // (gitsync.PushOutbound) and any push mirror to that target is taken down, or // Forgejo would go on force-pruning behind repoman's back on its own interval. OutboundPolicies map[string]OutboundPolicy `json:"outbound_policies,omitempty"` // OutboundBranchFilter limits which branches are pushed to the outbound // targets, as a comma-separated list of branch names or git refspec globs // ("main, release/*"). Empty — the default — means every branch. // // What it means depends on who is pushing, and the difference matters: // - repoman pushing (the default, any policy other than force+prune): the // filter selects what is sent, and with pruning off nothing else on the // target is touched. A branch the filter excludes is simply left alone on // both sides. // - Forgejo's push mirror (force+prune): the filter narrows the refspecs // but NOT the pruning — Forgejo hardcodes `Mirror: true` whatever the // filter is, so `git push -f --mirror` still deletes every ref the push // did not produce. There a filter makes the deleted set LARGER: Forgejo's // own branches that the filter excludes are deleted from the target too. // Reproduced against git 2.54; see migrate.warnPrunedTargetBranches. // // Forgejo has no API to update a configured mirror, so a change here is // applied by replacing the mirror (see migrate.ensurePushMirror). The value is // stored normalised; see NormalizeBranchFilter. OutboundBranchFilter string `json:"outbound_branch_filter,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.