Guide

Custom providers

Extend the abstract Provider for a platform this package does not know, implement the typed mappers, and get the ten resources for free.

The base class

ts
import { Provider } from "@agntn/forges";
import type { ProviderRawTypes } from "@agntn/forges";

Provider owns the ten resource accessors. Its constructor binds repos, issues, pullRequests and the rest to protected methods you implement, and does the argument checks that are the same everywhere: an empty search query, a repository scope without an owner, two assignees where the platform takes one.

Pass super() the platform name, the same one you hand normalizeError. Those checks and the 501 defaults below live in the base class. It knows nothing about your forge, so that name is how their refusals say who said no. Leave it out and they'll still refuse, just anonymously. Fine, until someone groups errors by platform.

A concrete class brings its raw response types and the mappers:

ts
interface MyRawTypes extends ProviderRawTypes {
  owner: MyRawOwner;
  repository: MyRawRepository;
  issue: MyRawIssue;
  pullRequest: MyRawPullRequest;
  user: MyRawUser;
  thread: MyRawThread;
  comment: MyRawComment;
}

export class MyProvider extends Provider<MyRawTypes> {
  constructor(config: ProviderConfig) {
    super("my-platform");
    // create an HTTP client with the platform's auth header
  }

  protected mapRepository(raw: MyRawRepository): Repository {
    return {
      id: String(raw.id),
      name: raw.name,
      fullName: raw.full_name,
      // …
    };
  }

  protected async getRepo(owner: string, repo: string): Promise<Repository> {
    try {
      const data = await this.client<MyRawRepository>(`/repos/${owner}/${repo}`);
      return this.mapRepository(data);
    } catch (error) {
      throw normalizeError(error, "my-platform");
    }
  }

  // listRepos, listIssues, getIssue, createIssue, listPullRequests, getPullRequest,
  // createPullRequest, listIssueComments, listPullRequestComments, getIssueComment,
  // getPullRequestComment, getUser, getAuthenticatedUser, getCommit,
  // listThreads, getThread, replyToThread, resolveThread, unresolveThread
  // Optional, 501 unless you override: listTags, listReleases, getRelease, createRelease,
  // updateRelease, updateIssue, updatePullRequest, mergePullRequest, createIssueComment,
  // createPullRequestComment
}

Mappers are pure: raw in, normalized out, nothing else. IDs are strings, always String(raw.id), because one platform sends numbers and the day you compare 123 === "123" is a bad day.

What you may skip

Repository contents reads, contribution templates, code search, CI runs, CI jobs and their logs, commit lists, commit comparisons, commit patch reads, commit checks, releases, issue and pull request search, pull request files, pull request checks, pull request reviews, issue and pull request updates, new discussion comments and reactions all have defaults that reject with a ForgesError of status 501 and a sentence naming the operation. Implement what your platform has. The rest fails honestly instead of pretending.

The building blocks

ts
import {
  createForgeClient,
  rawFetch,
  normalizeError,
  parseLinkHeader,
  paginate,
  fetchAllPages,
  cachedFetch,
} from "@agntn/forges";
  • createForgeClient(open, { scope }) is the client the shipped providers use. open resolves a forges provider, say bitbucket({ auth }) from forges/bitbucket, and every request rides its transport: the token goes to the API host and nowhere else, redirects and timeouts are its problem. A read that hits a 5xx goes again twice, a write never does. Call it with a path and { method, query, body, headers, responseType }. A refused request throws FetchError with status and data, and normalizeError turns that into a ForgesError. scope keeps cached answers of one host and token apart, so put both in it, the token hashed.
  • rawFetch returns the headers too, which a list needs for pagination.
  • parseLinkHeader reads a Link header into next, prev, last.
  • cachedFetch caches a GET and refuses anything else.
  • normalizeError(error, platform) maps a transport error onto the ForgesError hierarchy. Every operation ends with it. No raw throws, ever.

Pages

A list method gets the caller's position and says where the next page starts. Count pages? Read options.page and answer { items, hasNextPage, nextPage }. Your forge hands out a next link or a token instead? Read options.at, which is { url } or { token }, and answer { items, next } in the same shape. Either way the base class writes nextCursor for the caller and reads it back next time. A URL position has to stay on cursorOrigin(), so override it with your API origin when your lists hand out URLs. Leave it alone and a URL cursor is refused, which is the safe default. Sending a token along? Override cursorKey() with a key derived from it too, so a URL nobody signed never gets that token.

Sub path imports

ts
import { GitHubProvider } from "@agntn/forges/github";
import { GitLabProvider } from "@agntn/forges/gitlab";
import { GiteaProvider } from "@agntn/forges/gitea";
import { Provider } from "@agntn/forges/provider";
import type { Repository } from "@agntn/forges/types";

const gitea = new GiteaProvider({
  token: process.env.GITEA_TOKEN,
  baseURL: "https://codeberg.org",
});
console.log(gitea instanceof Provider); // true

One provider without the other two, and synchronous. createProvider also imports just the platform it was asked for, so the runtime cost is the same; the sub path matters for bundlers that resolve statically. Constructing a class directly skips the token detection of createProvider, so pass the token yourself.