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 for the same
RemoteAddress first, and Forgejo accepts an identical second mirror without
complaint.
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, which
must be the name Forgejo generated and reported through ListPushMirrors —
repoman cannot choose or predict it.
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. Callers must recognise an existing mirror by its
RemoteAddress — the name is Forgejo's, not theirs. 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.
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" (`"", false, nil`). Any other failure is
returned, because a caller must not read "cannot tell" as "safe to write".
The SHA is why there is no presence-only sibling to this. A tag can exist
in Forgejo and still be the wrong tag — the fabricated one has the right
name and answers 200 — so every caller here wants the commit, and offering a
weaker "does it exist" alongside it is how the next one picks the check that
cannot tell them apart.
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.TagCommitSHA) 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"`
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.
There is deliberately no remote name here: Forgejo's CreatePushMirrorOption
has no such field and NAMES THE REMOTE ITSELF ("remote_mirror_<random>"), so
a name sent with the request is silently dropped. Carrying one invited the
assumption that it could be used as the mirror's identity — see PushMirror.
type PushMirror struct {
RemoteName string `json:"remote_name"`
RemoteAddress string `json:"remote_address"`
}
PushMirror is one configured push mirror as Forgejo reports it.
RemoteAddress is the only field that identifies WHERE a mirror points,
and is therefore what callers must match an existing mirror on. Forgejo
reports it credential-free (it stores the address through util.SanitizeURL),
which is why comparing it is safe as well as necessary.
RemoteName is Forgejo's own generated handle for the git remote
("remote_mirror_<random>") — the server assigns it and ignores any name in
the create request, so it carries no meaning to repoman beyond being the key
DeletePushMirror needs. Do not derive it, read it back from here.
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.