Skip to content

Discover repositories in a namespace

Use this when your set of sources is a rule rather than a list: "every public repository in this namespace that carries a zensical.toml". It grows as the estate grows, with no configuration change.

Three optional capabilities serve it, and a provider may implement any subset. Discover what you need with forge.As and fall back when it is absent.

Use forge.As, not a bare type assertion

forge.As walks a decorator chain, mirroring errors.As. A wrapper that forwards only the four required Provider methods strips these interfaces from a direct assertion, and since absence is a graceful fallback, that turns discovery into a silent no-op rather than a visible failure.

Which backends implement these

The contract ships in forge v0.4.0. All four first-party providers implement Repositories and Contents; Sites is answered by GitHub, GitLab and Codeberg, refused by plain Gitea, and not implemented at all by Bitbucket. See the capability matrix.

Enumerate a namespace

var repos forge.Repositories
if !forge.As(provider, &repos) {
    return errNoDiscovery // this backend cannot enumerate
}

all, err := forge.CollectRepositories(ctx, repos, "phpboyscout", forge.RepositoryListOptions{
    IncludeSubgroups: true,
})
if errors.Is(err, forge.ErrNotSupported) {
    return errNoDiscovery
}
if err != nil {
    return err
}

CollectRepositories is the convenient path. To stop early, or to avoid holding the whole namespace at once, call the method directly:

err := repos.ListRepositories(ctx, "phpboyscout", opts, func(r forge.Repository) bool {
    if !interesting(r) {
        return true // keep going
    }

    found = append(found, r)

    return len(found) < 10 // false stops the enumeration
})

The error is not optional

A non-nil error means you hold a partial view. CollectRepositories returns the repositories it managed to collect alongside the error so you can see how far it got. A short list that looks complete is the failure mode this whole API is shaped to prevent. Never treat a partial result as the namespace.

That is why enumeration takes a callback and returns an error, rather than returning an iterator: an iter.Seq2[Repository, error] can be ranged with a single variable, which compiles cleanly, passes go vet, and discards every error.

Key on Path, never on anything else

Repository.Path is the canonical path the API answered with, which may not be the path you asked for. Forges redirect renamed and transferred projects, so two paths can resolve to one repository, and a directory name on disk can match neither.

seen[r.Path] = true // right

seen[filepath.Base(dir)] = true // wrong: local names drift from canonical ones

Enumeration also guarantees containment: every Path is inside the namespace you asked for. That is a real guarantee rather than an observation, because at least one forge defaults to breaking it: GitLab's project listing includes projects merely shared into a group, which carry foreign paths.

Test for a marker file without cloning

var contents forge.Contents
if !forge.As(provider, &contents) {
    return false, errNoContents
}

data, err := contents.GetFile(
    ctx, owner, repo, "zensical.toml", r.Git.DefaultBranch, forge.DefaultMaxFileSize,
)
switch {
case errors.Is(err, forge.ErrNotFound):
    return false, nil // no marker: does not qualify, and that is not an error
case err != nil:
    return false, err // could not check: retry, do NOT exclude
}

Use forge.SplitRepoPath(r.Path) to get owner and repo. It splits on the last separator, which matters on forges with nested namespaces. Splitting on the first instead requests a repository that does not exist, and the resulting 404 is indistinguishable from the file being absent.

maxBytes is required, and the provider enforces it. Pass forge.DefaultMaxFileSize unless you have a reason not to.

Treat visibility as a security boundary

If your predicate decides what gets published, republished or answered from, a loose visibility check is a disclosure rather than a bug.

// right: allowlist
if r.Visibility != forge.VisibilityPublic {
    continue
}

// wrong: admits VisibilityUnknown AND VisibilityInternal
if r.Visibility != forge.VisibilityPrivate {  }

VisibilityUnknown is the zero value and is reachable in practice, because a provider that cannot determine visibility reports it rather than guessing. internal exists on GitLab and is not public.

Two further rules, neither of which forge can enforce for you:

  • Evaluate exclusions first, and absolutely. A repository excluded by policy must never be reinstated by satisfying the predicate. Applied after discovery, an exclusion is one that can be forgotten.
  • Re-verify every cycle. Visibility and namespace membership change. Do not cache the verdict indefinitely; a repository made private must leave your set on the next refresh.

Ask whether a site is published

var sites forge.Sites
if !forge.As(provider, &sites) {
    return checkReachable(derivedURL) // fall back to your own probe
}

site, err := sites.GetSite(ctx, owner, repo)
switch {
case errors.Is(err, forge.ErrNotSupported), errors.Is(err, forge.ErrNotFound):
    return false, nil
case err != nil:
    return false, err // a permission failure is NOT "no site"
}

Before paying for that call, check the free signal. r.Site comes from the enumeration payload:

if r.Site == forge.SiteNone {
    continue // authoritative, no request needed
}

SiteUnknown does not mean "no site". It means the provider could not say from the listing alone.

Reachability is yours

Neither SiteStatus nor SiteState promises a browser gets a 200. SitePublished means configured; SiteStateBuilt means the last build succeeded. If your rule requires a reachable site, make the HTTP request yourself.

Check Site.URLSource before concluding anything from a failed probe:

  • SiteURLDerived: composed from a template rather than reported by the forge, so a custom domain living in DNS makes it the wrong address. A failed probe means unknown, not absent.
  • SiteURLGenerated: the forge's own generated host. It resolves, but it is not necessarily where the project publishes.
  • SiteURLCanonical: the project's configured domain. This is the one to cite.
  • SiteURLUnknown: the provider did not say, so treat provenance as unknown rather than assuming any of the above.

Site.URL is already the canonical address where the forge reports one, so you do not need to prefer anything yourself, because URLSource tells you which you got.

If you are citing URLs, check Site.TLS too. Where it is SiteTLSNotEnforced the URL may be http://, because the provider reports the scheme the forge recorded rather than guessing at TLS the forge will not promise. Upgrading it is your policy call to make, not the provider's.

Pin credentials before cloning

Repository.Git.CloneURL, Repository.URL and Site.URL are API-reported data, not constants. Attaching a credential to one without checking is the same mistake credential pinning exists to prevent:

if token != "" && forge.HostTrusted(r.Git.CloneURL, apiBaseURL) {
    // safe to authenticate
}