← repoman internals

internal/migrationconfig

import "git.griefed.de/griefed/repoman/internal/migrationconfig"
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.