← repoman internals

internal/forgejo

import "git.griefed.de/griefed/repoman/internal/forgejo"
package forgejo // import "git.griefed.de/griefed/repoman/internal/forgejo"

Package forgejo provides a minimal client for the Forgejo REST API.

Only the endpoints repoman actually uses are implemented:
  - Repo migration (POST /repos/migrate)
  - Repo existence check (GET /repos/{owner}/{repo})
  - Repo list for an owner (GET /repos/search)
  - Mirror setup (POST /repos/{owner}/{repo}/push_mirrors)
  - User/org existence (GET /users/{username}, GET /orgs/{org})

Ordinary calls share one 30s-timeout http.Client; the long, synchronous migrate
call uses a second client with no timeout, bounded by a per-call context
deadline instead (see migrateTimeout). All methods accept a context so the
worker can cancel in-flight requests when the user clicks "cancel" or SIGINT
arrives.

CONSTANTS

const DefaultMigrateTimeout = 2 * time.Hour
    DefaultMigrateTimeout bounds a single, synchronous repo migration when no
    explicit timeout is configured. Forgejo's /repos/migrate endpoint blocks
    until the clone AND every requested asset class (issues, pull requests,
    releases + attachments, LFS, wiki) has been pulled from the source —
    far longer than the 30s used for ordinary API calls, and Forgejo's own
    server-side MIGRATE git timeout defaults to 6000s. This ceiling sits
    above that so the server's error surfaces rather than the client cutting
    the request off, yet stays finite so a wedged migration can't block the
    single-run worker forever. Override via NewClient / the --migrate-timeout
    flag.


FUNCTIONS

func IsConflict(err error) bool
    IsConflict reports whether the error is a 409 (repo already exists).

func IsNotFound(err error) bool
    IsNotFound reports whether the error is a 404 from Forgejo.

func IsReleaseAlreadyExists(err error) bool
    IsReleaseAlreadyExists reports whether err is Forgejo refusing to create a
    release because one already exists for that tag.

    READ THIS BEFORE CHANGING IT. Forgejo answers 409 in exactly two shapes
    here, and one of them is a MISNOMER that means the opposite of how it reads:

      - "ReleaseAlreadyExist" — the obvious one
        (repo_model.IsErrReleaseAlreadyExist).
      - "Release has no Tag" — emitted when `!rel.IsTag`, i.e. the row it found
        IS already a real release rather than a bare tag placeholder. It does
        NOT mean the tag is missing.

    A genuinely absent tag does not reach here at all, and NOT because it fails:
    with target_commitish omitted, Forgejo defaults the target to the repo's
    default branch and CREATES the tag there (see TagExists). It only errors —
    404 `target "" not found` — when there is no default branch to resolve, i.e.
    an empty repo. Callers must gate on TagExists rather than rely on a failure.

    So any 409 from CreateRelease means "already there" — adopt the existing
    release and carry on to its assets rather than treating it as an error.

func IsTransientMigrateError(err error) bool
    IsTransientMigrateError reports whether err is a Forgejo migrate failure
    that a retry might clear: a 5xx from Forgejo itself, or a 422 whose embedded
    clone stderr carries one of transientCloneSignatures. A permission/auth
    (403), not-found, or conflict failure returns false — retrying those is
    futile.

func IsUnprocessable(err error) bool
    IsUnprocessable reports whether Forgejo returned 422.

func MigrateErrorHint(err error) string
    MigrateErrorHint returns a short, actionable one-line hint for a well-known
    migrate failure, or "" when none applies (so the caller appends nothing). It
    translates two common, opaque forge errors into next steps: a clone that the
    token can list but not download (scope/role), and a transient source error.


TYPES

type APIError struct {
	Method  string
	Path    string
	Status  int
	Message string
}
    APIError is returned when Forgejo responds with a 4xx or 5xx status.

func (e *APIError) Error() string
    Error renders the failed request's method, path, HTTP status, and server
    message.

type Client struct {
	// Has unexported fields.
}
    Client is a Forgejo API client.

func NewClient(baseURL, token string, skipTLS bool, migrateTimeout time.Duration) *Client
    NewClient constructs a Client. skipTLS is only for development against
    self-signed certificates — never set it true in production. migrateTimeout
    caps a single (synchronous) migration; a value <= 0 falls back to
    DefaultMigrateTimeout.

func (c *Client) AddPushMirror(ctx context.Context, owner, name string,
	mirror MirrorPushTarget) error
    AddPushMirror sets up a push mirror on an existing Forgejo repo. Not
    idempotent on its own — callers check ListPushMirrors first.

func (c *Client) CreateIssue(ctx context.Context, owner, repo string, req CreateIssueRequest) (*Issue, error)
    CreateIssue creates an issue and returns it (with its assigned number).

func (c *Client) CreateIssueComment(ctx context.Context, owner, repo string, index int64, body string) (*IssueComment, error)
    CreateIssueComment posts a comment on an issue and returns it (with its id).

func (c *Client) CreateRelease(ctx context.Context, owner, repo string, req CreateReleaseRequest) (*Release, error)
    CreateRelease creates a release on an existing repo and returns it (with its
    new ID). Metadata-only, so it uses the ordinary 30s client.

func (c *Client) DeletePushMirror(ctx context.Context, owner, name, remoteName string) error
    DeletePushMirror removes the push mirror identified by remoteName (the
    "mirror-<platform>" name repoman assigns).

    It exists because a mirror outlives the reason it was created. When the
    remote becomes read-only — the repo archived on that platform — repoman
    stops writing to it, but Forgejo goes on pushing on its own interval and
    failing, in ITS logs rather than the run log where an operator would see it.
    Leaving the mirror is a silent, permanent failure; removing it is the only
    way to actually stop it.

    A 404 is success: the mirror is already gone, which is the state the caller
    wants.

func (c *Client) DeleteReleaseAttachment(ctx context.Context, owner, repo string, releaseID, attachmentID int64) error
    DeleteReleaseAttachment removes one attachment from a release by ID.
    It is used to drop a partial/corrupt upload (size mismatch or a failed
    streamed-digest check) so the next run re-attempts cleanly. Deleting a
    missing attachment is reported by Forgejo as 404, which the caller may treat
    as already-gone.

func (c *Client) DeleteRepo(ctx context.Context, owner, name string) error
    DeleteRepo deletes a repo. Idempotent — 404 is treated as success.

func (c *Client) DownloadReleaseAttachment(ctx context.Context, attachment ReleaseAttachment) (io.ReadCloser, error)
    DownloadReleaseAttachment streams a Forgejo release attachment's bytes
    so they can be re-published to an outbound mirror (release HA). It uses
    the no-timeout client (bounded by ctx) so a large asset isn't cut off,
    and authenticates with the admin token. The caller closes the returned body.

func (c *Client) EditIssue(ctx context.Context, owner, repo string, index int64, req EditIssueRequest) (*Issue, error)
    EditIssue updates an issue's title/body/state and returns the updated issue.

func (c *Client) EditIssueComment(ctx context.Context, owner, repo string, commentID int64, body string) (*IssueComment, error)
    EditIssueComment edits an existing comment by its id (the edit endpoint is
    repo-scoped, not under the issue index) and returns the updated comment.

func (c *Client) EnableReleases(ctx context.Context, owner, repo string) error
    EnableReleases turns on the repo's releases unit via
    EditRepoOption.HasReleases. Forgejo DISABLES that unit during a migration
    that imports no releases — which is exactly what repoman now does, since it
    imports releases itself — and a disabled unit makes the releases API 404
    and hides the Releases tab (where git tags are also listed). Idempotent:
    enabling an already-enabled unit is a no-op.

func (c *Client) EnsureIssueWebhook(ctx context.Context, owner, repo, targetURL, secret string) (bool, error)
    EnsureIssueWebhook provisions the issue-hub webhook on a Forgejo repo,
    idempotently: if a hook already delivers to targetURL it is left as-is,
    otherwise one is created firing issues + issue_comment events to targetURL,
    signed with secret. Returns true when it created a new hook. This is what
    makes hub edits actually reach repoman without the operator wiring the
    webhook by hand.

func (c *Client) GetIssue(ctx context.Context, owner, repo string, index int64) (*Issue, error)
    GetIssue fetches one issue by its index. Returns (nil, nil) when not found.

func (c *Client) GetReleaseByTag(ctx context.Context, owner, repo, tag string) (*Release, error)
    GetReleaseByTag fetches the release bound to a tag, so a caller that raced
    a concurrent create (or listed before one appeared) can adopt the existing
    release instead of failing. Returns nil with no error when the repo has no
    release for that tag.

func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error)
    GetRepo fetches a single repo. Returns (nil, nil) when not found so callers
    can use existence checks cleanly.

func (c *Client) ListIssueComments(ctx context.Context, owner, repo string, index int64) ([]IssueComment, error)
    ListIssueComments returns one issue's comments, oldest first, paginated.

func (c *Client) ListIssues(ctx context.Context, owner, repo string) ([]Issue, error)
    ListIssues returns every issue (not PR) of a repo, open and closed,
    paginated.

func (c *Client) ListPushMirrors(ctx context.Context, owner, name string) ([]PushMirror, error)
    ListPushMirrors returns the repo's configured push mirrors. It exists so
    AddPushMirror can be made idempotent: Forgejo does not reject a duplicate,
    it happily configures the same remote twice, which would push every ref
    twice on every interval. A 404 (no such repo, or the unit is off) yields
    no mirrors and no error, because the caller treats "none configured" and
    "cannot tell" the same way — it will try to add one.

func (c *Client) ListReleases(ctx context.Context, owner, repo string) ([]Release, error)
    ListReleases returns every release of a repo (paginated), drafts included,
    so the caller can match source releases by tag and skip already-uploaded
    attachments. Each Release carries its ID and existing attachment names.

    The `draft` query parameter is deliberately NOT sent. Forgejo maps it to
    `IsDraft: ctx.FormOptionalBool("draft")`, which is a FILTER — `draft=true`
    returns *only* drafts, it does not mean "include drafts". Sending it made
    every published release invisible here, so the release sync believed each
    one was missing, tried to create it, and got a 409 back; assets were never
    backfilled. Omitting the parameter leaves the filter unset and returns both
    kinds (drafts are visible because the admin token has write access — Forgejo
    gates them on `IncludeDrafts`, not on this parameter). Don't "clarify" this
    by adding `draft=true` back.

func (c *Client) ListRepoWebhooks(ctx context.Context, owner, repo string) ([]Webhook, error)
    ListRepoWebhooks returns the repo's webhooks, so a caller can check
    whether the hook it wants already exists before creating it (idempotent
    provisioning).

func (c *Client) ListRepos(ctx context.Context, owner string) ([]*Repo, error)
    ListRepos returns all repos for a Forgejo owner (user or org). It paginates
    automatically until all pages are retrieved.

func (c *Client) ListTags(ctx context.Context, owner, repo string) ([]Tag, error)
    ListTags returns every git tag in the repo. A 404 (the code unit disabled,
    or a repo whose git content never landed) yields no tags and no error,
    because the caller treats "no tags" and "cannot see tags" the same way —
    both mean a release referencing a tag cannot be created yet.

func (c *Client) MigrateRepo(ctx context.Context, req MigrateRequest) (repo *Repo, backgrounded bool, err error)
    MigrateRepo calls POST /repos/migrate, returning once the import is
    confirmed (or definitively failed). Forgejo's API runs the migration
    on a DETACHED graceful context, NOT the request context, so a reverse
    proxy that returns 502/503/504 long before a large clone + asset/LFS pull
    completes does *not* abort the import — Forgejo keeps working server-side.
    MigrateRepo therefore treats a gateway error (or a dropped connection) as
    "still importing" and polls the repo instead of falsely reporting the proxy
    timeout as a failure. The whole operation is bounded by migrateTimeout.

    The returned backgrounded flag is true when the POST was cut off by a proxy
    timeout and MigrateRepo confirmed the git import via polling, but Forgejo
    is STILL importing releases/assets/LFS server-side — which the public API
    gives no way to observe to completion (only `empty` is exposed, and it
    reflects git only). Callers should report such a repo as "import accepted,
    assets still importing", not as a fully-verified success. A synchronous 201
    returns backgrounded=false (everything, assets included, is done).

    Returns an *APIError with Status 409 if the repo already exists —
    callers should check IsConflict(err). A real Forgejo error (422, auth,
    …) is returned unchanged; only proxy/transport-level failures fall through
    to polling.

func (c *Client) MirrorSync(ctx context.Context, owner, repo string) error
    MirrorSync asks Forgejo to pull a mirrored repo from its upstream now,
    instead of waiting for the repo's mirror_interval. The release sync calls
    it when a source release names a tag the mirror has not fetched yet: Forgejo
    mirrors carry git refs but never releases, so repoman creates the releases
    itself and must not run ahead of the refs they point at. Forgejo queues
    the pull and answers immediately, so a successful call means "accepted",
    not "finished".

func (c *Client) RepoExists(ctx context.Context, owner, name string) (bool, error)
    RepoExists reports whether a repo already exists in Forgejo.

func (c *Client) ServerVersion(ctx context.Context) (string, error)
    ServerVersion returns the Forgejo/Gitea version string reported by GET
    /api/v1/version. It is the cheapest authenticated round-trip that exercises
    both connectivity and the token, so the settings UI uses it as a "test
    connection" probe.

func (c *Client) TagCommitSHA(ctx context.Context, owner, repo, tag string) (string, bool, error)
    TagCommitSHA returns the commit tag resolves to in the Forgejo repo,
    and whether the tag is there at all. A 404 is a definite "no tag" (false,
    nil); any other failure is returned, for the same reason TagExists returns
    one — a caller must not read "cannot tell" as "safe to write".

    The SHA is what makes a tag verifiable rather than merely present. A tag
    can exist in Forgejo and still be the wrong tag: the release API fabricates
    a missing tag on the default branch's tip, so the name matches while the
    history underneath it does not. Comparing this against the source's own
    answer is the only way to tell those apart.

func (c *Client) TagExists(ctx context.Context, owner, repo, tag string) (bool, error)
    TagExists reports whether the repo currently has this git tag.

    It is the gate that stops repoman making Forgejo FABRICATE a tag.
    A release created for a tag Forgejo does not have is not refused:
    Gitea/Forgejo's CreateRelease does `if len(form.Target) == 0 { form.Target
    = DefaultBranch }` and then creates the missing tag at that target — so an
    omitted target_commitish silently plants the tag on the default branch's tip
    instead of the commit the release actually belongs to. Observed in the wild:
    six prerelease tags from an alpha branch all landed on main's tip, which
    broke semantic-release's version detection for the whole repository.

    A 404 is a definite "no tag". Any other failure is returned, because a
    caller must not read "cannot tell" as "safe to create".

func (c *Client) UploadReleaseAttachment(ctx context.Context, owner, repo string,
	releaseID int64, name string, content io.Reader) (*ReleaseAttachment, error)
    UploadReleaseAttachment streams content as a multipart attachment named name
    onto releaseID, returning the created attachment (with its ID and Size). The
    body is piped (not buffered) so a multi-gigabyte asset never sits in memory,
    and it uses the no-timeout client bounded by ctx — matching the asset
    download side. The caller is responsible for closing the content reader's
    underlying source. The returned ID lets a caller delete the attachment if a
    post-upload integrity check (e.g. a streamed-digest mismatch) fails.

type CreateIssueRequest struct {
	Title  string `json:"title"`
	Body   string `json:"body"`
	Closed bool   `json:"closed"`
}
    CreateIssueRequest is the payload for POST /repos/{owner}/{repo}/issues.
    Closed creates the issue already-closed (so a closed source issue imports
    faithfully).

type CreateReleaseRequest struct {
	TagName    string `json:"tag_name"`
	Name       string `json:"name,omitempty"`
	Body       string `json:"body,omitempty"`
	Draft      bool   `json:"draft"`
	Prerelease bool   `json:"prerelease"`
}
    CreateReleaseRequest is the payload for POST /repos/{owner}/{repo}/releases.

    TargetCommitish is intentionally absent, and the CALLER must ensure the tag
    already exists (Client.TagExists) before posting this. Omitting the target
    does NOT mean "bind to the existing tag only": Forgejo defaults the target
    to the repo's default branch and mints a missing tag there, so posting this
    for a tag Forgejo has not fetched yet plants that tag on the wrong commit —
    silently, with a 201. Do not add a TargetCommitish field as the fix either;
    a tag must come from the source's real history (internal/gitsync), never be
    fabricated at a guess.

type CreateWebhookRequest struct {
	Type   string            `json:"type"`   // "forgejo"
	Active bool              `json:"active"` // deliver immediately
	Events []string          `json:"events"` // ["issues","issue_comment"]
	Config map[string]string `json:"config"` // url / content_type / secret
}
    CreateWebhookRequest is the payload for POST /repos/{owner}/{repo}/hooks.
    The issue hub provisions a "forgejo" hook firing issues + issue_comment
    events to repoman's ingress URL, JSON-encoded and HMAC-signed with the
    per-config secret.

type EditIssueRequest struct {
	Title string `json:"title"`
	Body  string `json:"body"`
	State string `json:"state"` // "open" | "closed"
}
    EditIssueRequest is the payload for PATCH …/issues/{index}. All three
    fields are always sent — the sync engine computes the full desired
    title/body/state.

type Issue struct {
	Number    int64       `json:"number"`
	Title     string      `json:"title"`
	Body      string      `json:"body"`
	State     string      `json:"state"`
	HTMLURL   string      `json:"html_url"`
	User      IssuePoster `json:"user"`
	CreatedAt string      `json:"created_at"`
	UpdatedAt string      `json:"updated_at"`
	// PullRequest is non-nil when the item is actually a PR; the hub holds only
	// issues, but list responses are filtered defensively on this.
	PullRequest *struct{} `json:"pull_request"`
}
    Issue is a Forgejo issue (the hub side of the issue sync). Number is the
    per-repo index used in API paths; State is "open" or "closed".

type IssueComment struct {
	ID        int64       `json:"id"`
	Body      string      `json:"body"`
	User      IssuePoster `json:"user"`
	CreatedAt string      `json:"created_at"`
	UpdatedAt string      `json:"updated_at"`
}
    IssueComment is a comment on a Forgejo issue. ID is the comment's own id
    (used to edit it via /issues/comments/{id}).

type IssuePoster struct {
	Login string `json:"login"`
}
    IssuePoster is the author login carried on issues and comments.

type MigrateRequest struct {
	CloneAddr      string `json:"clone_addr"`
	RepoOwner      string `json:"repo_owner"`
	RepoName       string `json:"repo_name"`
	Description    string `json:"description,omitempty"`
	Private        bool   `json:"private"`
	Mirror         bool   `json:"mirror"`
	MirrorInterval string `json:"mirror_interval,omitempty"`

	// Service is the source downloader type Forgejo uses to pull non-git
	// metadata (releases, wiki, issues, …): "github" | "gitlab" | "gitea" |
	// "git" (plain clone, no metadata). Must be a platform value for the
	// item flags below to take effect; empty/"git" yields a git-only import.
	Service string `json:"service,omitempty"`

	// Metadata item flags — honoured only for a one-time full migration
	// (Mirror=false) with a platform Service. Forgejo MIRRORS sync git refs
	// only and ignore these. Releases includes attached assets + descriptions.
	Wiki         bool `json:"wiki,omitempty"`
	Issues       bool `json:"issues,omitempty"`
	Milestones   bool `json:"milestones,omitempty"`
	Labels       bool `json:"labels,omitempty"`
	PullRequests bool `json:"pull_requests,omitempty"`
	Releases     bool `json:"releases,omitempty"`
	LFS          bool `json:"lfs,omitempty"`

	// AuthToken is the token used to clone from the source.
	AuthToken string `json:"auth_token,omitempty"`
	// AuthUsername / AuthPassword for basic auth sources.
	AuthUsername string `json:"auth_username,omitempty"`
	AuthPassword string `json:"auth_password,omitempty"`
}
    MigrateRequest is the payload for POST /repos/migrate. Forgejo clones the
    repo from CloneAddr using the supplied credentials.

type MirrorPushTarget struct {
	RemoteAddress  string `json:"remote_address"`
	RemoteName     string `json:"remote_name"`
	RemoteUsername string `json:"remote_username,omitempty"`
	RemotePassword string `json:"remote_password,omitempty"`
	Interval       string `json:"interval,omitempty"` // e.g. "8h"
}
    MirrorPushTarget is one entry in POST /repos/{owner}/{repo}/push_mirrors.

type PushMirror struct {
	RemoteName    string `json:"remote_name"`
	RemoteAddress string `json:"remote_address"`
}
    PushMirror is one configured push mirror as Forgejo reports it. RemoteName
    is the identity repoman keys on (it sets "mirror-<platform>"), so an
    existing mirror can be recognised without comparing credential-bearing URLs.

type Release struct {
	ID         int64  `json:"id"`
	TagName    string `json:"tag_name"`
	Name       string `json:"name"`
	Body       string `json:"body"`
	Draft      bool   `json:"draft"`
	Prerelease bool   `json:"prerelease"`
	// CreatedAt is when Forgejo recorded the release. Read back because it is
	// what Forgejo sorts its "latest" badge by, so the outbound publisher can
	// republish releases in the same order rather than newest-first.
	CreatedAt time.Time `json:"created_at"`
	// TargetCommitish is where the release was bound WHEN IT WAS CREATED, and it
	// is evidence rather than decoration. Forgejo writes a BRANCH NAME here only on
	// the create path — the one that runs when the repo has no tag of that name and
	// that mints the tag on the named branch's tip. A release bound to a tag Forgejo
	// already had records an empty target (or the explicit commit a client passed),
	// so a branch name in this field means "this release's tag did not exist when it
	// was published", which is the fingerprint the release sync looks for. It does
	// not move when the tag is later corrected, so it stays readable after a repair.
	TargetCommitish string              `json:"target_commitish"`
	Assets          []ReleaseAttachment `json:"assets"`
}
    Release is a Forgejo release. For inbound sync only the tag, ID and
    attachment names matter (idempotency); the title/body/draft/prerelease
    fields are read back too so OUTBOUND release HA can recreate the same
    release on a mirror target (GitHub/Codeberg).

type ReleaseAttachment struct {
	ID          int64  `json:"id"`
	Name        string `json:"name"`
	Size        int64  `json:"size"`
	DownloadURL string `json:"browser_download_url"`
}
    ReleaseAttachment is one file attached to a Forgejo release. Name decides
    whether a target still needs it; DownloadURL is where its bytes are
    fetched from when re-publishing to an outbound mirror. ID and Size support
    integrity repair: a stored attachment whose Size differs from the source's
    is a partial (interrupted) upload, deleted by ID and re-uploaded. (Forgejo
    exposes no checksum for attachments, so Size is the integrity signal on the
    stored side.)

type Repo struct {
	ID          int64  `json:"id"`
	Name        string `json:"name"`
	FullName    string `json:"full_name"`
	Private     bool   `json:"private"`
	Archived    bool   `json:"archived"`
	Mirror      bool   `json:"mirror"`
	Description string `json:"description"`
	CloneURL    string `json:"clone_url"`
	HTMLURL     string `json:"html_url"`

	// Empty reports whether the repo has no git content yet. During a migration
	// the repo record appears before its history is cloned, so Empty stays true
	// until the git import lands — pollMigration uses it as the "git arrived"
	// gate. NOTE: Empty flips to false the moment git lands, which is BEFORE
	// release/LFS asset import finishes; Forgejo's API exposes no "assets done"
	// flag, so a 504-then-poll can only confirm git, not assets (see MigrateRepo).
	Empty bool `json:"empty"`
}
    Repo is a minimal representation of a Forgejo repository.

type Tag struct {
	Name   string    `json:"name"`
	Commit TagCommit `json:"commit"`
}
    Tag is a git tag as Forgejo reports it: its name, and the commit it resolves
    to. The name alone answers "are the refs behind?"; the commit answers the
    harder question of whether the tag Forgejo holds is the SAME tag the source
    has, which is what stops a release binding to fabricated history.

type TagCommit struct {
	SHA string `json:"sha"`
}
    TagCommit is the commit a tag points at. Forgejo reports the PEELED commit
    here even for an annotated tag (whose own object id is the sibling `id`
    field), which is what makes it comparable with the other platforms' tag
    APIs.

type Webhook struct {
	ID     int64 `json:"id"`
	Config struct {
		URL string `json:"url"`
	} `json:"config"`
}
    Webhook is the subset of a Forgejo repo webhook repoman cares about: its id
    and the delivery URL, which is enough to detect (by URL) a hook it already
    provisioned and avoid creating duplicates.