@gitkraken/milestones-tools
v0.7.1
Published
Client library for GitKraken user milestones and demographic metadata (identity-service).
Readme
@gitkraken/milestones-tools
Client library for GitKraken user milestones and demographic metadata, served by identity-service.
What it is
This package wraps the v1 milestone and demographic surface of identity-service in a runtime-agnostic client. It does two things:
- Milestones - emits canonical events (commit, branch, PR, AI feature) from actions the host already observes, queues each event durably, and delivers it to identity-service, which computes milestones from the event stream. Payloads are validated against an embedded copy of the canonical event manifest, so the client and the server agree on the taxonomy without a round-trip.
- Demographics - reads and writes the user's demographic metadata and the enum sets that constrain each field.
The library never performs HTTP itself, never reads a token from ambient state, and never persists anything directly. The host injects all three: a request function (the same { body, status, headers } shape GitKraken's other client SDKs use, e.g. @gitkraken/provider-apis), a token getter, and a storage port.
Install
pnpm add @gitkraken/milestones-toolsProvide the ports
Everything the library needs from the outside world is passed through MilestonesSdkConfig:
import { createMilestonesClient } from '@gitkraken/milestones-tools';
const client = createMilestonesClient({
baseUrl: 'https://api.gitkraken.dev',
// Identifies the host surface reporting milestones (e.g. 'gitlens').
sourceClient: 'gitlens',
// Optional. Default client_version on observed payloads; a per-action value wins.
clientVersion: '11.2.0',
// Injected request function - omit to use a default fetch-based requester.
// Plug in your own (e.g. GitKraken's contextFreeMakeRequest) to own the network.
// `headers` is a Web `Headers` instance (case-insensitive lookups). A fetch
// response already exposes one; wrap a plain object with `new Headers(obj)`.
request: async ({ method, url, headers, body }) => {
const response = await myHttp({ method, url, headers, body });
return { body: response.body, status: response.status, headers: new Headers(response.headers) };
},
// Called per request to supply a fresh bearer token.
getToken: async () => await myAuth.getAccessToken(),
// Durable key/value store - back it with disk, localStorage, etc.
storage: {
get: async (key) => myStore.get(key) ?? null,
set: async (key, value) => myStore.set(key, value),
delete: async (key) => myStore.delete(key),
},
// Optional - how long loadMilestones() trusts its cache (default 5 min).
cacheTtlMs: 5 * 60 * 1000,
// Optional structured logger.
logger: console,
});StoragePort is the only stateful dependency. The library uses it to cache milestone state and to persist the pending-event queue, so emitted events survive process restarts and are delivered at least once.
Milestones
const { milestones } = client;
// Load the user's achieved milestones (cached for cacheTtlMs).
const state = await milestones.loadMilestones();
console.log(Object.keys(state.achieved));
// Get notified when a queued event is accepted by the server.
const unsubscribe = milestones.onConfirmed((action) => {
console.log('server accepted', action.type);
});
// Feed actions the host already observes. The client validates each action
// against the embedded manifest, enqueues it as a canonical event, and flushes
// it to identity-service (POST /v1/milestones/events); the server computes
// milestones from the event stream.
//
// `observe` takes a typed ClientAction (a discriminated union keyed by event).
// Build it with the `actions` helpers, passing only the event's own fields (a
// mistyped `kind` will not compile). `surface_name` is filled from `sourceClient`
// and `client_version` from `clientVersion`.
import { actions } from '@gitkraken/milestones-tools';
await milestones.observe(actions.commitCreated({ kind: 'create' }));
await milestones.observe(actions.branchCreated());
// The equivalent union literal is also valid if you prefer it:
await milestones.observe({
type: 'commit_created',
at: new Date().toISOString(),
payload: { kind: 'create' },
});
// Cheap local check against the cached state.
if (await milestones.isAchieved('first_commit')) {
hideFirstCommitHint();
}
unsubscribe();Every recognized event is emitted; the server, not the client, decides which events earn a milestone (e.g. whether a cherrypick commit counts). Unknown action types are ignored.
Payloads are checked at runtime against the manifest (required fields, string types, enum membership) as a backstop for untyped callers. A payload that fails validation is logged via the injected logger and skipped rather than emitted; extra fields are allowed. validatePayload(event, payload) is exported if you want to check before observing.
Delivery is host-pumped (no background work)
The library owns no timer, interval, or retry scheduler. A queued event is (re)sent only when the host next calls observe() or flush(). This is deliberate: durability (the StoragePort) and knowing when it is worth retrying (app lifecycle, network state) are the host's to inject, not the library's to assume. A library that spun its own timers would be harder to test deterministically, would keep the process alive, and would duplicate the online/lifecycle knowledge the host already has.
The consequence: for a rare, once-ever milestone, a single failed flush (offline, 5xx) stays queued but undelivered until the next flush. So the host must pump the queue. Recommended pattern:
flush()on service init (drains anything persisted from a prior run),flush()on network-online,flush()on a modest interval as a safety net.
observe() flushes inline, so steady-state delivery is automatic; the init/online/interval flushes cover the offline-then-reconnect case. If you want a different retry cadence, inject your own QueuePort via config.queue.
Demographics
const { demographic } = client;
const enums = await demographic.getEnums();
const current = await demographic.get();
const updated = await demographic.set({ role: 'softwareEngineer', gitComfortLevel: 'very' });Canonical manifest
The canonical v1 event taxonomy is embedded in the package and exported as manifest, alongside the eventByName(name) helper. It is validated with zod at module load, so a malformed manifest fails fast. It carries the event names and payload schemas the client validates against; milestone computation lives on the server.
import { manifest, eventByName } from '@gitkraken/milestones-tools';
console.log(manifest.events.map((event) => event.event_name));
console.log(eventByName('commit_created')?.payload_schema.required); // ['surface_name', 'kind']Contributing
This package follows the monorepo's standard tooling - see the root CONTRIBUTING.md. Local commands:
pnpm --filter @gitkraken/milestones-tools build
pnpm --filter @gitkraken/milestones-tools typecheck
pnpm --filter @gitkraken/milestones-tools testEnd-to-end tests
pnpm test (and CI) runs unit tests only. The e2e suite hits a live identity-service and is opt-in:
MILESTONES_E2E_TOKEN=<bearer-token> pnpm --filter @gitkraken/milestones-tools test:e2eEnv vars: MILESTONES_E2E_TOKEN (required); MILESTONES_E2E_BASE_URL (optional, defaults to https://devapi.gitkraken.com; gateway that fronts identity-service, no trailing slash, no /v1); MILESTONES_E2E_SOURCE_CLIENT (optional, defaults to gitlens); MILESTONES_E2E_WRITE=1 (optional) to also run the mutating milestone POST. Without the token the suite skips itself.
