Repositories
Get and list
const repo = await forge.repos.get("nitrojs", "nitro");
const page = await forge.repos.list("nitrojs", { perPage: 30 });
get reads fresh every time. list reads one page of an owner's repositories.
Agents get a lighter row. forges_repos_list drops owner when it repeats the owner the call asked for, whatever the letter case. fullName still spells the login. On pages of 25 to 30 repositories from GitHub, GitLab and Gitea that was a fifth to a quarter of the text. A project another namespace shares with a GitLab group keeps its owner. details in Pi and OMP still carry the whole row.
The shape
interface Repository {
id: string; // always a string, even when the API sends a number
name: string;
fullName: string; // "owner/name"
description: string; // "" when the platform has none
private: boolean;
defaultBranch: string;
url: string;
cloneUrl: string;
isFork: boolean;
parent: { fullName: string; url: string } | null; // null when not a fork, or hidden
viewerPermission: "none" | "read" | "triage" | "write" | "maintain" | "admin" | null;
merge: {
methods: ("merge" | "squash" | "rebase")[];
defaultMethod: "merge" | "squash" | "rebase" | null;
squashTitle: string | null; // GitHub only, e.g. "COMMIT_OR_PR_TITLE"
squashMessage: string | null; // GitHub only, e.g. "COMMIT_MESSAGES"
deleteBranchOnMerge: boolean | null;
} | null; // null when the platform keeps it to itself
owner: { login: string; avatarUrl: string };
}
Two fields deserve a sentence. parent is the immediate upstream of a fork, null when there is none or the platform hides it. viewerPermission is your highest role on the repo. null means the platform said nothing about access, not that you have none. Anonymous calls always get null, do not read anything into it.
How it merges
merge answers the question worth asking before git commit. How will this land? Take a repository that squashes with squashTitle: "COMMIT_OR_PR_TITLE". A pull request with one commit gets that commit's subject as its merged title. So the subject better be good already.
methods lists what pullRequests.merge will take, so the default goes straight into a merge call. GitLab never lists rebase: its project setting decides whether merge fast forwards. Gitea's rebase-merge and fast-forward-only styles have no shared name, so they stay out. A default the repository no longer allows is dropped too.
Now the catch. GitHub sends these settings only to a token with push access, and never on a list page. GitLab wants a signed-in caller. Gitea tells anyone. Cloudflare Artifacts has no pull requests to merge, so it's always null there. Everywhere else null means nobody told us, not that nothing is allowed. Agents see the same split: forges_repos_list leaves merge off every row, forges_repos_get carries it.
Owners that are groups
On GitLab, /users/:owner/projects is a 404 for a group. repos.list falls back to /groups/:owner/projects on that one status and re throws everything else, so a real 404 stays a NotFoundError instead of turning into a second guess.
Private repositories on GitHub
/users/:owner/repos lists public repositories only, whatever token you hold, and it answers for organizations too. repos.list asks /orgs/:owner/repos first, and when the owner is the login behind the token it reads /user/repos instead, so your own private repositories and the ones your organizations share with you are in the page. Anonymous calls take the public route straight away.
Files and directories
const readme = await forge.repos.readContents("nitrojs", "nitro", "README.md");
const atTag = await forge.repos.readContents("nitrojs", "nitro", "README.md", { ref: "v2.10.0" });
const src = await forge.repos.readContents("nitrojs", "nitro", "src");
readContents answers with type: "file" or type: "directory", and both carry sha, the commit the read resolved ref to. Leave ref out and you get the default branch. "" or "/" lists the root.
A file comes back as one slice of text, 20 000 UTF-16 code units by default and 200 000 at most. size is in bytes. When nextOffset isn't null, there's more:
if (readme.type === "file" && readme.nextOffset !== null) {
const next = await forge.repos.readContents("nitrojs", "nitro", "README.md", {
ref: readme.sha,
offset: readme.nextOffset,
});
}
Continue with the returned sha, never the branch name. A continuation that names a branch gets a 409, because a push between two slices would stitch two versions of the file together and nobody would notice. A binary file says binary: true with empty content instead of a wall of base64. Anything above 1 MiB is a 413: GitHub's contents API stops sending content there, so the other two stop too and the limit means the same thing everywhere. GitHub and Gitea answer a submodule with a 422. A path with .. in it is refused before any request goes out.
A directory lists entries with name, path, type (file, directory, symlink, submodule) and size, which is null for anything that isn't a file and for everything on GitLab, since its tree API doesn't send sizes. entriesComplete is null on GitHub at 1 000 entries, its cap for one listing, and false on GitLab after ten pages of 100.
GitHub and Gitea spend one extra request resolving a branch or tag to a SHA. Pass a full SHA and it's skipped. GitLab's files API reports the commit itself, so a file there needs no separate lookup.
Tags
const page = await forge.repos.listTags("nitrojs", "nitro", { perPage: 10 });
const newest = page.items[0]; // { name, sha }
What's the newest tag, and did it ever get a release? releases.list can't tell you. A tag needs no release, and plenty of repositories never write one. listTags reads the tags themselves.
Each row is a name and a sha, and the sha is a commit, never a tag object. An annotated tag is a Git object with a hash of its own. Nice for Git, useless next to git rev-parse HEAD. So it's peeled for you, on all three.
A tag can also point at a tree. Linux has one, v2.6.11-tree. GitHub admits there's no commit behind it, so sha is null. GitLab doesn't. It names the head of the default branch instead, which makes a 2.6 kernel tag point at Linux 7.3-rc6. The tell is the date. A tag on a tree has none, and no real tag is older than its commit. So an annotated tag like that costs one extra read of its target on GitLab, and a 404 there means null too. GitLab before 16.11 sends no tag date at all, and there the sha stays as it came.
Only GitLab takes an order, and it's asked for versions, so v1.10.0 lands before v1.9.0. GitHub and Gitea promise nothing. In practice GitHub sorts by version too and Gitea by date, newest first. Is the first row the newest tag then? Usually. A hotfix tagged on an old branch last week tops the list on Gitea and sits near the bottom elsewhere. Compare names when it matters.
Artifacts has no route for refs, so it answers listTags with a 501.
Pagination
Every list returns:
interface PageResult<T> {
items: T[];
nextCursor?: string; // absent on the last page
totalCount?: number; // when the platform counts
}
GitHub and Gitea say what is next in a Link header, GitLab in x-next-page, Artifacts in a token. All of it ends up in nextCursor. Pass it back as cursor, unchanged, and you get the next page:
let cursor: string | undefined;
do {
const page = await forge.repos.list("nitrojs", { cursor, perPage: 100 });
for (const repo of page.items) console.log(repo.fullName);
cursor = page.nextCursor;
} while (cursor);
To walk everything:
import { fetchAllPages, paginate } from "@agntn/forges";
const all = await fetchAllPages(fetcher, url); // one array
for await (const page of paginate(fetcher, url)) {
// one page at a time
}
Both take a raw fetcher and a URL. They are the pieces the providers are built from, exported for custom ones.
Caching
Stable reads go through an LRU, five minutes and five hundred entries by default, scoped to the base URL and a hash of the token. Repository, issue, pull request, comment and user item reads skip it, because you call those to check current state and a stale answer there is worse than no cache. Lists use it.
const forge = await createProvider("github", {
cache: { ttl: 60_000, enabled: true },
});
cache.enabled: false turns it off for one provider. Mutations never touch it.