---
title: "Data model"
description: "How refs, threads, notifications and events describe the same things on every forge."
canonical_url: "https://forges.link/concepts/data-model"
---
# Data model

> How refs, threads, notifications and events describe the same things on every forge.

Forges name and number things in different ways. What GitHub calls a pull request is a merge request on GitLab, and the identifiers, states and fields differ from forge to forge as well. To spare you from handling each case, `forges` describes everything with a small set of shared types. As a result, code that handles an issue from GitHub handles an issue from Forgejo too.

## Refs

A ref is how you point at a resource. It is a plain object, so you can build one by hand, store it, or send it between processes. Every ref has a `forge` and an `instance`, and what comes on top depends on the resource:

- A `RepoRef` adds `owner` and `name`. On GitLab, `owner` can contain slashes to describe nested groups, while `name` never does.
- A `ThreadRef` adds the `repo`, a `kind` and a `number`. The `number` is absent from some refs in events and notifications. A `ResolvedThreadRef` is one that has it, and every thread, comment and review that a provider returns carries one.
- Comments, releases, webhooks, checks and notifications have refs of their own.

```ts
const repo = {
  forge: 'gitlab',
  instance: 'gitlab.com',
  owner: 'acme/platform',
  name: 'widgets',
} as const
const ref = {
  forge: 'gitlab',
  instance: 'gitlab.com',
  repo,
  kind: 'pull_request',
  number: '23',
} as const
```

The `forge` names the kind of forge, such as `'github'`. The `instance` identifies one particular deployment of it. It defaults to the host of that deployment, such as `'github.com'` or `'ghe.example.com'`, and you can give a provider a different value if you need to tell two providers for the same host apart.

### Thread kinds

A thread is anything that has a conversation attached. Its `kind` is one of `issue`, `pull_request`, `discussion`, `commit` or `other`. A GitLab merge request has `kind: 'pull_request'`, so you don't need to care about the different names. If a subject has no shared kind, such as a check suite, its kind is `other`, and `typeRaw` holds the name that the forge uses for it.

### Keys

Refs are objects, which makes them awkward as map keys or database columns. For these cases, `repoKey()`, `threadKey()`, `commentKey()`, `releaseKey()` and `notificationKey()` turn a ref into a stable string, and `parseRepoKey()`, `parseThreadKey()`, `parseCommentKey()`, `parseReleaseKey()` and `parseNotificationKey()` read it back. When the forge has its own ID for a repository, the key uses it, so renaming or transferring the repository doesn't change the key. The keys of threads, comments and releases start with the key of their repository, followed by `#`.

A ref that you build by hand has no forge ID, so its key differs from the key of the same repository as a provider returns it. To keep keys stable, store the refs that a provider returns.

To learn which keys an event or a notification touches, pass it to `affectedKeys()`. It returns the keys grouped by kind, without a request, so you can drop the rows that you cached for them:

```ts
import { affectedKeys } from 'forges'

const { repos, threads, comments } = affectedKeys(event)
for (const key of [...repos, ...threads, ...comments]) {
  cache.delete(key)
}
```

[Keep a cache up to date](https://forges.link/concepts/events#keep-a-cache-up-to-date) explains what each kind of event touches.

If you want a string for people to read rather than for a program, `repoSlug()` and `threadSlug()` return the form that people type, such as `acme/widgets#42`. `repoRef()` reads that form back for a repository. Pass it a provider, which supplies the `forge` and the `instance`:

```ts
import { repoRef } from 'forges'

const repo = repoRef(provider, 'acme/platform/widgets')
```

### Forge IDs

Some objects also carry the forge's own ID. On GitHub, `Actor.id` and `RepoRef.externalId` are the numeric IDs of the REST API, which the GraphQL API calls `databaseId`. `ThreadRef.externalId` is the global node ID, which the GraphQL API calls `id`. If you compare these values with the results of your own GraphQL queries, select the field that matches.

### Organisation teams

An organisation's teams are `Team` objects, and each team's members are `Actor`s. A `TeamRef` carries the organisation as a namespace `RepoRef`, the team's `slug` and, where the forge has one, its `id`, so it needs no repository. Pass a team's `ref` to `orgs.teamMembers()`, or, for a team you know by slug, a ref with only `org` and `slug`:

```ts
const org = { forge: 'github', instance: 'github.com', owner: 'acme', name: '', kind: 'namespace' } as const

for await (const team of provider.orgs.teams(org)) {
  for await (const member of provider.orgs.teamMembers(team.ref)) {
    console.log(team.name, member.login)
  }
}
```

GitLab has no teams of its own, so a subgroup plays the role of one: `orgs.teams()` lists every subgroup of a group, nested ones included, and `orgs.teamMembers()` lists a subgroup's direct members. On GitHub both reads need a credential; on GitLab listing subgroups does not, but listing members does. On Forgejo and Gitea both need a token. On Azure DevOps teams belong to a project, so the organisation ref is a project namespace (`owner: 'organization/project'`). The [capability matrix](https://forges.link/reference/capability-matrix) shows which forges have teams at all.

## Normalised and raw values

A `Thread` has a `state` that is one of `open`, `closed`, `merged` or `unknown`. It also has `labels`, `assignees`, an `author` and timestamps, which are `Date` objects. A pull request adds `branches`, `checks`, `stack` and `stats`. The `stats` field counts the lines added and removed, the files changed and the commits, as far as the forge reports them with the pull request. Forges often leave these counts out of listings, so a pull request from `threads.list()` can lack counts that `threads.get()` returns.

A merged pull request has `mergedAt` and `mergedBy` where the forge reports them. Listings and search results often carry only `mergedAt`, so read the pull request with `threads.get()` to learn who merged it. On Bitbucket a pull request has only `mergedBy`, and on Gitee, Pushin.eu, Cursor Origin and Tangled only `mergedAt`.

Sometimes a forge has a value that the shared model can't express. Instead of losing it, the model keeps both versions: a thread's `state` has a matching `stateRaw`, a notification's `reason` has `reasonRaw`, and an event's `action` has `actionRaw`. The `raw` field goes one step further and holds the complete, unnormalised payload from the forge. As a rule, use the normalised values in your code, and reach for the raw ones only when you need a detail that is specific to one forge.

## Pages and totals

Listings come back as a `Page`, which holds `items` and, while there is more to read, a `cursor`. A page has `total` when the forge reports how many items the whole listing holds. A forge that knows the exact count sends it in the body or in a pagination header: GitHub's search and Actions responses carry `total_count`, GitLab sends `X-Total`, and Forgejo, Gitea and Gitee report a count too. Where a forge only numbers its pages, `total` is the last page number times the page size from its `Link` header, so it can be a little larger than the exact count when the final page is short. An absent `total` means the forge reported none, so treat it as unknown rather than zero.

## Optional fields

A field is optional when some forges don't report it. If a forge doesn't return a comment count, for example, `thread.commentCount` is `undefined` and not `0`. A missing number and a number that is zero mean different things, so check for `undefined` before you use an optional field.

## Write results

A write resolves with what the forge's response says about the change, so you can update a cached row without reading it again. Writes that create or edit something resolve with it: `threads.comment()` with the `Comment`, `threads.update()`, `close()` and `reopen()` with the `Thread`, `approve()` with the `Review`. Writes that change one part of a thread resolve with that part: `setLabels()` with the labels, `setAssignees()` with the assignees, `merge()` with a `MergeResult`. A value can hold less than a full read where the response does, such as labels without colours on GitLab. Writes such as `requestReview()`, `react()` and `subscribe()` resolve with nothing, so read again where you need what they changed.

## Warnings

A result can carry `warnings`. A warning describes a problem that didn't make the call fail, such as a filter that the forge ignored. To learn how to read and handle them, see [Handle errors and rate limits](https://forges.link/guides/errors#warnings).

## Next steps

- [Capabilities](https://forges.link/concepts/capabilities) explains how support differs by forge.
- [Events](https://forges.link/concepts/events) describes webhook deliveries and live streams.
- The [provider reference](https://forges.link/reference/provider) lists every namespace and method.


## Sitemap

See the full [sitemap](https://forges.link/sitemap.md) for all pages.
