package source // import "git.griefed.de/griefed/repoman/internal/source" Package source provides clients for fetching repository lists from source Git platforms. Each platform implements the Client interface. Supported platforms (each has a default URL that a source may override with a custom instance URL — so self-hosted GitLab/Gitea/GitHub Enterprise is "<platform> + URL override", not a distinct platform): - github (default https://github.com; Enterprise via override) - gitlab (default https://gitlab.com; self-hosted via override) - gitea (default https://gitea.com; self-hosted via override) - codeberg (default https://codeberg.org — runs Forgejo/Gitea API) VARIABLES var ErrAssetDeleteUnsupported = errors.New("source: release-asset delete not supported for this platform") ErrAssetDeleteUnsupported is returned by a ReleasePublisher.DeleteReleaseAsset for a platform that cannot delete a release asset by the identity repoman holds (GitLab, whose assets are package-registry-backed links with no size to verify against in the first place). Callers treat it as "leave the existing asset". TYPES type AccessVerifier interface { VerifyAccess(ctx context.Context) error } AccessVerifier confirms a source client's token authenticates — a cheap "whoami" call used as a run-start preflight so an invalid/expired/revoked token surfaces clearly and early, before any migration work. It is a type-asserted optional interface (like TokenInspector / ReleasePublisher), not part of Client, so callers verify it when present and skip it otherwise. Scope: this checks the token is VALID, not that it has write access to a specific namespace — repoman never writes to a source on import, and outbound write-permission still surfaces at push time. It catches the most common credential failure uniformly across platforms. type Client interface { // ListRepos returns all repos for the configured namespace. // For user namespaces this is the user's repos; for org namespaces // it is the org's repos. ListRepos(ctx context.Context) ([]*Repo, error) ReleaseClient IssueClient IssueWriter } Client fetches repositories, releases and issues from a source platform. It embeds ReleaseClient and IssueClient so a single platform client serves the repo inventory, the release/asset import, and the issue hub; callers needing only one surface can depend on the narrower interface instead. func NewClient( platform, namespace, token string, ownerType migrationconfig.OwnerType, instURL string, ) (Client, error) NewClient returns a Client for the given source platform. instURL is the optional web-base URL override; when empty the platform's default (migrationconfig.PlatformDefaultURL) is used. type Issue struct { // Number is the platform's issue number (GitHub/Gitea number, GitLab iid). Number int64 Title string Body string // State is normalised to "open" or "closed" (GitLab's "opened" is mapped). State string // Author is the original author's login/username, preserved in the synced // copy's attribution header (no platform lets us post as another user). Author string // URL links to the original issue (also used in the attribution header). URL string CreatedAt time.Time UpdatedAt time.Time } Issue is an issue on a source platform. Pull/merge requests are excluded by the clients (the issue hub syncs issues only), so callers never see them. type IssueClient interface { // ListIssues returns every issue (not pull/merge requests) of repo, open and // closed. ListIssues(ctx context.Context, repo string) ([]*Issue, error) // ListIssueComments returns the comments on one issue, oldest first, skipping // system/auto-generated notes. ListIssueComments(ctx context.Context, repo string, issueNumber int64) ([]*IssueComment, error) } IssueClient reads issues and their comments from a source platform — the inbound half of the issue hub. It is embedded in Client; every source platform implements it. type IssueComment struct { ID string Body string Author string CreatedAt time.Time UpdatedAt time.Time } IssueComment is one comment on a source issue. ID is the platform's comment id rendered as a string (uniform across platforms; stored in comment_links.source_comment_id). Auto-generated/system notes are excluded. type IssueSpec struct { Title string Body string State string } IssueSpec is the desired content of an issue when creating or editing it on a source platform (the outbound half of the hub). State is "open" or "closed". type IssueWriter interface { // CreateIssue creates an issue (honouring spec.State, closing it after create // where the platform cannot create-closed) and returns it with its number. CreateIssue(ctx context.Context, repo string, spec IssueSpec) (*Issue, error) // EditIssue updates an existing issue's title/body/state. EditIssue(ctx context.Context, repo string, number int64, spec IssueSpec) error // CreateIssueComment posts a comment and returns its platform id as a string. CreateIssueComment(ctx context.Context, repo string, issueNumber int64, body string) (string, error) // EditIssueComment edits a comment. issueNumber is required by some platforms // (GitLab notes are addressed by issue + note id); others ignore it. EditIssueComment(ctx context.Context, repo string, issueNumber int64, commentID, body string) error } IssueWriter creates and edits issues + comments on a source platform — the outbound half of the issue hub. Embedded in Client; every source platform implements it. The per-platform quirks (GitHub/GitLab can't set state at create time; GitLab edits a note by issue+note id; GitLab uses state_event) are hidden behind these methods. type Release struct { // ID is the platform's numeric release id. It is populated when listing a // platform's releases (github/gitea) and is what OUTBOUND release HA uses to // attach assets to an existing remote release; it is 0/unused on the inbound // path where releases are matched by tag. ID int64 // TagName is the git tag the release points at (e.g. "v1.2.3"). It is the // stable identity used to match a source release to a Forgejo one, so a // re-run skips releases that already exist instead of duplicating them. TagName string // Name is the human-facing release title; may be empty (defaults to the tag). Name string // Body is the release notes / changelog markdown. Body string // Draft reports an unpublished release; Prerelease a pre-GA one. Both are // carried across so the Forgejo release keeps the same status. Draft bool Prerelease bool // CreatedAt is the source publish/creation time, kept for reference only. CreatedAt time.Time // Assets are the files attached to the release. Source-archive entries that // platforms auto-generate from the tag are deliberately excluded — Forgejo // regenerates those from the imported git tag, so re-uploading them is waste. Assets []ReleaseAsset } Release is a single release on a source platform together with its downloadable binary assets. repoman imports releases itself — rather than letting Forgejo's migrate pull them — because Forgejo's bundled importer is serial, slow, and exposes no completion signal, and because Forgejo MIRRORS drop releases entirely. See internal/migrate for the sync that consumes this. type ReleaseAsset struct { // ID is the platform's numeric asset id, populated when listing a target's // releases so a mismatched copy can be deleted before re-upload (github/gitea). // 0 when the platform exposes none (gitlab links) or on the inbound path. ID int64 // Name is the file name as it should appear on the Forgejo release. Name string // Size is the asset size in bytes as the platform reports it (0 when unknown); // informational only — the actual bytes come from DownloadAsset. Size int64 // ContentType is the MIME type the platform reported (may be empty). ContentType string // DownloadURL is the platform URL DownloadAsset fetches the bytes from. It is // opaque to callers: only the owning Client knows how to authenticate it. DownloadURL string // Digest is the platform-reported content digest as "algo:hex" (e.g. // "sha256:abc…"), or empty when the platform exposes none. GitHub populates it // (asset.digest, since 2025); GitLab/Gitea/Codeberg do not. When present it // lets a (re)upload verify the streamed bytes in-flight (see uploadAsset). Digest string } ReleaseAsset is one downloadable file attached to a Release. DownloadURL is platform-specific and is fetched with that platform's auth scheme via the owning Client's DownloadAsset, so callers never hand-roll the credentials. type ReleaseClient interface { ReleaseLister // DownloadAsset opens the bytes of asset for streaming, authenticating with // the platform's scheme. The caller MUST Close the returned reader. The body // is streamed (not buffered) so multi-gigabyte assets don't sit in memory. DownloadAsset(ctx context.Context, asset *ReleaseAsset) (io.ReadCloser, error) } ReleaseClient is the inbound (read) surface: list a platform's releases and stream their assets down, for importing INTO Forgejo. Client embeds it. type ReleaseLister interface { // ListReleases returns every release of repo (the short name; the namespace // is fixed by the Client) with its non-source assets and platform ID, newest // first where the platform orders them. An empty slice means no releases. ListReleases(ctx context.Context, repo string) ([]*Release, error) } ReleaseLister lists a platform's releases — shared by the inbound importer (read source releases) and the outbound publisher (read what a mirror already has, for idempotency). type ReleasePublisher interface { ReleaseLister // TagExists reports whether tag is already present on the target repo. The // publisher must NOT create a release before its tag has been pushed there // (by the git mirror), or the platform would fabricate the tag at the wrong // commit — so the caller gates CreateRelease on this. TagExists(ctx context.Context, repo, tag string) (bool, error) // CreateRelease creates a release from spec and returns it. The returned // *Release carries whatever the platform needs to attach assets to it next — // a numeric ID (github/gitea) or just the tag (gitlab). CreateRelease(ctx context.Context, repo string, spec ReleaseSpec) (*Release, error) // UploadReleaseAsset streams asset onto rel. It takes the whole *Release // because platforms address a release differently (by ID or by tag), and an // UploadAsset rather than a bare reader because a streamed upload needs to // declare its length and to be re-openable (see UploadAsset). Streamed (not // buffered) so multi-GB assets stay out of memory. UploadReleaseAsset(ctx context.Context, repo string, rel *Release, asset UploadAsset) error // DeleteReleaseAsset removes asset from rel so a partial/size-mismatched copy // can be re-uploaded (release-HA integrity repair). asset carries the platform // identity it needs — ID for github/gitea. A platform that can't delete by that // identity returns ErrAssetDeleteUnsupported, which the caller treats as // "keep the existing asset" rather than a hard failure. DeleteReleaseAsset(ctx context.Context, repo string, rel *Release, asset *ReleaseAsset) error } ReleasePublisher is the outbound (write) surface: publish Forgejo releases onto a mirror target so downstream users can fetch them from any platform ("release HA"). Implemented by github and gitea/codeberg; NOT by every platform (GitLab's link/package-registry model is deferred), so callers type-assert to it and skip targets that don't support it. It reuses ListReleases to stay idempotent. type ReleaseSpec struct { TagName string Name string Body string Draft bool Prerelease bool } ReleaseSpec is the metadata needed to create a release on a target platform — the write counterpart of Release, free of platform IDs. type Repo struct { // Name is the short name, e.g. "my-project". Name string // Namespace is the owner, e.g. "alice" or "my-org". Namespace string // CloneURL is the HTTPS clone URL including authentication, // suitable for passing to Forgejo's migration API. // Format: https://<token>@host/namespace/name.git // or: https://oauth2:<token>@host/namespace/name.git CloneURL string // CloneURLNoAuth is the clean public URL without credentials, // used for display and logging. CloneURLNoAuth string // Description is the repo description (may be empty). Description string // Website is the repo's homepage/website URL (may be empty). GitLab projects // have no such field, so it is always empty there. Website string // Topics are the repo's topic labels (GitHub topics, GitLab topics, // Gitea/Codeberg topics). Nil when the platform reported none. Topics []string // Private reports whether the source repo is private. Private bool // Archived reports whether the source repo is archived. Archived bool // UpdatedAt is the source repo's last push/activity time. It is the // tiebreaker the GoRunner uses to pick the most up-to-date copy when the // same repo name appears on more than one configured source. Zero when // the platform did not report it (the dedupe then falls back to source // order). Mapped per platform: GitHub pushed_at, GitLab last_activity_at, // Gitea/Codeberg updated_at. UpdatedAt time.Time } Repo is a repository found on a source platform. type RepoMetadata struct { Description string // Website is skipped on platforms without the concept (GitLab). Website string // Topics replaces the target's topic set wholesale. Topics []string // Private, when non-nil, ENFORCES the target's visibility: true makes it // private, false makes it public. Nil — the default — leaves the target's // visibility alone entirely, which is what an ordinary metadata sync does. // // It is a pointer rather than a bool precisely so "do not touch visibility" // and "make it public" are different requests; a plain bool would make every // metadata sync silently assert public. Only a config with // Settings.ForcePrivacyOverrideOnTargets set passes it. // // GitLab note: its three-way visibility (private/internal/public) is read as // Private = (visibility != "public"), so an INTERNAL project compares equal to // a private source and is therefore left as internal rather than narrowed to // private. Enforcement only ever moves a target that genuinely disagrees. Private *bool } RepoMetadata is the descriptive metadata mirrored onto a target. Avatars are NOT here, and that is a platform limitation rather than an oversight: GitHub exposes **no public API** for a repository's avatar or its social-preview image (only an undocumented internal upload endpoint the web UI calls), so an avatar field would work on some targets and silently do nothing on GitHub. Rather than sync metadata that is quietly partial, avatars are left to the operator. Revisit only if GitHub ships a real endpoint. type RepoProvisioner interface { // RepoExists reports whether repo exists in the client's namespace. A missing // repo is (false, nil); only a genuine transport/API failure returns an error, // so a caller can tell "absent" from "could not tell". RepoExists(ctx context.Context, repo string) (bool, error) // CreateRepo creates an empty repository in the client's namespace. It is the // caller's job to only call this when RepoExists reported false; a platform // asked to create an existing repo returns its own already-exists error. CreateRepo(ctx context.Context, spec RepoSpec) error // EnsureReleasesEnabled makes repo able to ACCEPT releases and their assets, // because a target with the feature switched off rejects every publish — and // does so with an opaque status rather than a "feature disabled" message: // - Gitea/Forgejo: the `releases` repo unit can be off, and uploads then fail // (observed as HTTP 502 on an asset upload). Fixed by PATCHing // has_releases, the same field forgejo.Client.EnableReleases uses. // - GitLab: `releases_access_level` off makes the API answer 403 on EVERY // release endpoint regardless of token scope, and repoman streams GitLab // assets through the Generic Package Registry, so // `package_registry_access_level` must be on as well. // - GitHub: releases cannot be disabled, so this is a documented no-op. // Idempotent — enabling an already-enabled feature is a no-op on every // platform, so it is safe to call before each sync. EnsureReleasesEnabled(ctx context.Context, repo string) error // SyncMetadata copies descriptive metadata onto the target, OVERWRITING what // is there. A mirror is meant to look like what it mirrors, so a value edited // on the target is replaced rather than preserved — including being cleared // when the source has none. // // It writes only when the target actually DIFFERS. Implementations read the // target's current metadata first and return (false, nil) when it already // matches, because an unconditional write is both pointless churn and actively // harmful: a repo archived on the target rejects every write, so re-PATCHing // metadata that was already correct turned each run into a warning // ("Repository was archived so is read-only") for a repo that had nothing to // sync. A failed read falls through to the write, so an unreadable target // behaves as before rather than silently skipping the sync. // // changed reports whether the target was actually written to. The caller needs // it to log a visibility change HONESTLY: without it the run log announced // "enforcing visibility" once per repo per target whether or not anything // moved, which is noise on a large config and a claim about an action that may // not have happened. // // Not every field exists everywhere and the missing ones are skipped, not // faked: GitLab projects have no website/homepage field, and Gitea/Forgejo // keep topics behind a separate endpoint from the rest of the repo settings. // Avatars are deliberately absent from this seam — see RepoMetadata. SyncMetadata(ctx context.Context, repo string, meta RepoMetadata) (changed bool, err error) // ArchiveRepo marks repo archived — read-only — on the target platform, so a // project whose Forgejo hub has been archived is visibly finished everywhere it // is mirrored instead of looking live on every platform but the hub. // // One direction only, deliberately. UNarchiving is not offered: it would have // repoman reopen a repository a person deliberately froze on someone else's // platform, and there is no signal that distinguishes "the hub was unarchived // on purpose" from "the hub was never archived and this target was frozen by // hand". Reviving a mirror stays a human action. // // Idempotent on every platform: archiving an already-archived repo is a no-op // (GitLab documents this explicitly), so a caller may call it without checking // — though the engine skips targets it already knows are archived. // // Platform shapes differ and that is why this is its own method rather than a // RepoMetadata field: GitHub and Gitea/Forgejo take `archived` on the ordinary // repo PATCH, while GitLab has no such field and archives through a dedicated // POST /projects/:id/archive endpoint. ArchiveRepo(ctx context.Context, repo string) error } RepoProvisioner is the optional capability an outbound target needs for repoman to create the mirror destination itself. Why it exists: a Forgejo push mirror pushes to a remote that must ALREADY exist — no platform auto-creates it. Before this seam, outbound targets were wired up only while importing a repo for the first time, so enabling "sync back to" for a platform AFTER the import left every existing repo with no destination: the release publisher then hit 404 on the target's releases and tags on every run, forever. Implemented by the github, gitlab and gitea (also Codeberg/Forgejo) clients via a type assertion, in the same style as ReleasePublisher, AccessVerifier and TokenInspector — a platform that cannot provision simply does not satisfy it, and the caller reports that instead of failing the run. type RepoSpec struct { Name string Description string Private bool } RepoSpec describes a repository to create on an outbound target. Visibility is carried over from what is being mirrored rather than defaulted, so a private repo never becomes public by being mirrored. type TagCommitResolver interface { // TagCommitSHA returns the commit tag resolves to on repo, and whether the tag // exists there at all. A missing tag is ("", false, nil), not an error. TagCommitSHA(ctx context.Context, repo, tag string) (string, bool, error) } TagCommitResolver answers what commit a tag points at on the source platform. It is an OPTIONAL, type-asserted seam (see docs/adding-a-platform.md): a platform that does not implement it simply skips the check, which is the pre-existing behaviour. Callers must not require it. It exists because "Forgejo has a tag with that name" is a weaker statement than it looks. Forgejo's release API mints a missing tag on the default branch's tip, so a fabricated tag is indistinguishable from a real one BY NAME — the six prerelease tags that landed on one repository's main tip all "existed". Only the commit tells them apart, and only the source can say what the commit should be. The returned SHA is the PEELED COMMIT, never an annotated tag's own object id, because that is the one value every platform can agree on: Forgejo and Gitea report it as commit.sha, GitLab as commit.id, and GitHub needs its tag object dereferenced to reach it. Comparing anything else would report an annotated tag as diverged from itself. type TokenIdentifier interface { TokenIdentity(ctx context.Context) (TokenIdentity, error) } TokenIdentifier reports who a token belongs to. Optional and type-asserted, like TokenInspector and ReleasePublisher, so a caller that has only a Client asks for it when present and degrades to a plain reachability check when not. type TokenIdentity struct { // Account is the login the token authenticates as ("Griefed"). Always set on // a successful probe — it is the whole point of asking. Account string // Name is the account's display name, when the platform exposes one. Name string // Scopes are the permissions the platform reports for THIS token, sorted. // Empty where the platform does not tell us (Gitea/Forgejo expose no scope // listing for the token in use) — empty means "unknown", never "none". Scopes []string } TokenIdentity is what a token turns out to BE when you ask the platform: the account it authenticates as and, where the platform will say, what it is allowed to do. It answers the question a "test token" button is really asking — not "does this string work" but "whose token is this, and what can it reach". type TokenInspector interface { TokenExpiry(ctx context.Context) (expiresAt time.Time, known bool, err error) } TokenInspector reports the configured access token's expiry, when the platform exposes it. It is satisfied by all three concrete clients but kept separate from Client (like ReleasePublisher / WebhookProvisioner) so callers type-assert it. The `known` return distinguishes "the platform told us an expiry" from "there is nothing to warn about" — the latter covers a non-expiring token AND platforms with no token-expiry concept at all (Gitea/Forgejo). err is only for a failed probe, never for a missing/absent expiry. type UploadAsset struct { // Name is the file name the asset should have on the target. Name string // Size is the exact number of bytes Open will yield. It becomes the request's // Content-Length, so a wrong value fails the upload loudly (net/http refuses a // body that does not match) rather than truncating it silently. Zero means the // asset is EMPTY and is checked as such (see openUploadBody) — it is not a // stand-in for "unknown", and a negative size is rejected. Size int64 // Open starts a fresh read of the bytes. The caller closes what it returns. Open func() (io.ReadCloser, error) } UploadAsset is one file on its way ONTO a release: what to call it, how many bytes it is, and how to start reading them. Size and Open exist because "just hand me a reader" is not enough for a streamed HTTP upload. Without a known length Go cannot set Content-Length and falls back to chunked transfer encoding, which GitHub's upload endpoint rejects outright with "400 Bad Content-Length" — every asset, every run. And a one-shot reader cannot be replayed, so net/http refuses to retry a request whose body was already written ("cannot retry err ... after Request.Body was written; define Request.GetBody"), turning an ordinary REFUSED_STREAM into a lost upload. Open is called once per attempt, so the bytes come fresh each time. type WebhookProvisioner interface { // EnsureIssueWebhook idempotently ensures a webhook on repo delivers issue + // comment events to targetURL, signed/authenticated with secret. It returns // true when it created a new hook, false when an equivalent one already // existed (matched by delivery URL). EnsureIssueWebhook(ctx context.Context, repo, targetURL, secret string) (bool, error) } WebhookProvisioner provisions repoman's issue-hub webhook on a source repo so source-side issue/comment edits are delivered back to repoman. It is satisfied by all three concrete clients (github/gitlab/gitea, the last also serving codeberg) but kept separate from Client — like ReleasePublisher — so callers type-assert it and a future platform without an implementation degrades gracefully rather than failing to compile.