New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

@mattstack/glance

Package Overview
Dependencies
Maintainers
1
Versions
19
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@mattstack/glance

GitHub and GitLab API client: REST, GraphQL, and real-time ActionCable subscriptions

latest
Source
npmnpm
Version
0.27.0
Version published
Maintainers
1
Created
Source

@mattstack/glance

Provider-agnostic SDK for GitHub and GitLab: types, REST/GraphQL clients, real-time subscriptions, and dashboard helpers. Works in any Node/Bun runtime.

Install

npm install @mattstack/glance
# or
bun add @mattstack/glance

Quick Start

import { GitLabProvider } from '@mattstack/glance';

const provider = new GitLabProvider('https://gitlab.com', process.env.GITLAB_TOKEN!);
const user = await provider.validateToken();
const prs = await provider.fetchPullRequests();

Providers

Two built-in providers implement the GitProvider interface:

ProviderClassCapabilities
GitLabGitLabProviderFull: merge, rebase, approve, auto-merge, retry pipeline, re-review, resolve discussions
GitHubGitHubProviderCore: merge, approve, rebase
import { createProvider } from '@mattstack/glance';

// Auto-create the right provider by slug
const provider = createProvider('gitlab', 'https://gitlab.com', token);

// With optional logger
const provider = createProvider('gitlab', 'https://gitlab.com', token, {
  logger: console
});

Provider Methods

MethodDescription
validateToken()Verify credentials, returns UserRef
fetchUser(username)Resolve a username to a UserRef, or null if no such user
fetchPullRequests(options?)Fetch MRs — supports state filter and batch iids fetching
fetchSingleMR(path, iid, userId)Fetch one MR by project path and IID
fetchPullRequestByBranch(path, branch, state?)Find an MR by source branch
fetchPullRequestsByBranches(path, branches)Batch-fetch MRs by source branches (optional, not all providers)
createPullRequest(input)Create a new MR
updatePullRequest(path, iid, input)Update an existing MR (title, description, draft status, etc.)
mergePullRequest(path, iid, input?)Merge an MR
rebasePullRequest(path, iid)Rebase an MR onto its target branch
approvePullRequest(path, iid)Approve an MR
unapprovePullRequest(path, iid)Remove your approval
setAutoMerge(path, iid)Enable auto-merge when pipeline passes
cancelAutoMerge(path, iid)Cancel auto-merge
retryPipeline(path, pipelineId)Retry a pipeline
requestReReview(path, iid, usernames?)Request re-review
resolveDiscussion(path, iid, discussionId)Resolve a discussion thread
unresolveDiscussion(path, iid, discussionId)Unresolve a discussion thread
watchMR(path, iid, userId, onUpdate, options?)Real-time MR subscription (returns dispose())
fetchMRDiscussions(repoId, iid)Fetch MR discussions and notes
fetchBranchProtectionRules(path)Fetch branch protection rules
deleteBranch(path, branch)Delete a branch
restRequest(method, path, body?)Raw authenticated REST API pass-through

Provider capabilities are exposed via provider.capabilities — check flags like canMerge, canRebase, canAutoMerge, canResolveDiscussions, etc. before calling provider-specific methods.

Dashboard API

createDashboard

High-level factory that bundles real-time MR watching with pre-bound mutation actions.

Single MR by IID

import { createDashboard } from '@mattstack/glance';

const dashboard = createDashboard({
  provider,
  projectPath: 'group/project',
  mrIid: 42,
  userId
});

// Actions are available immediately
await dashboard.actions.merge();
await dashboard.actions.approve();
await dashboard.actions.toggleDraft(false); // mark ready

// Subscribe delivers real-time MRDashboardProps on each update
dashboard.subscribe((mr) => {
  console.log(mr.status, mr.pipeline?.status);
  renderUI(mr, dashboard.actions);
});

// Monitor connection health
dashboard.onStatusChange((status) => {
  console.log(status.connection); // 'connecting' | 'connected' | 'disconnected' | 'reconnecting'
});

// When done, stop the subscription
dashboard.dispose();
PropertyTypeDescription
actionsMRDashboardActionsPre-bound mutation methods — available immediately
isInitialLoadingbooleantrue until the first data payload arrives
subscribe(cb)(cb: (mr: MRDashboardProps) => void) => voidRegister a listener for real-time updates
onStatusChange(cb)(cb: (status: WatcherStatus) => void) => voidMonitor connection health
dispose()() => voidStop the subscription and clean up

Single MR by branch name

const dashboard = createDashboard({
  provider,
  projectPath: 'group/project',
  branch: 'feat/my-feature',
  userId
});

// Resolves the MR IID from the branch automatically.
// Re-resolves on each poll, so it picks up new MRs on the same branch.
dashboard.subscribe((mr) => renderUI(mr, dashboard.actions));

Multi-MR: DashboardGroup

Pass an array of IIDs to watch multiple MRs over a single WebSocket:

const group = createDashboard({
  provider,
  projectPath: 'group/project',
  mrIid: [42, 43, 44, 45],
  userId
});

group.subscribe((mrs) => {
  for (const [iid, mr] of mrs) {
    renderRow(mr, group.actionsFor(iid));
  }
});

// Dynamically update the tracked IIDs without destroying the group
group.updateIids([42, 43, 44, 45, 46]);
group.updateIids(prev => prev.filter(id => id !== 43));
PropertyTypeDescription
actionsFor(iid)(iid: number) => MRDashboardActionsGet actions for a specific MR
isInitialLoadingbooleantrue until the first data payload arrives
subscribe(cb)(cb: (mrs: Map<number, MRDashboardProps>) => void) => voidReal-time updates for all MRs
updateIids(iids)(iids: number[] | (prev: number[]) => number[]) => voidDynamically add/remove tracked MRs
onStatusChange(cb)(cb: (status: WatcherStatus) => void) => voidMonitor connection health
dispose()() => voidStop all subscriptions and clean up

getMRDashboardProps

Lower-level helper that transforms a raw PullRequest into render-ready MRDashboardProps. Useful when you already have the MR data from fetchPullRequests() or fetchSingleMR():

import { getMRDashboardProps } from '@mattstack/glance';

const prs = await provider.fetchPullRequests();
const dashboards = prs.map(getMRDashboardProps);
// dashboards[0].status → 'mergeable' | 'blocked' | 'draft' | ...
// dashboards[0].mergeButton.visible, .disabled, .label
// dashboards[0].provider → 'gitlab' | 'github'

MRDashboardProps

Pre-computed, UI-ready props — no conditional logic needed in the component layer:

FieldDescription
provider'gitlab' or 'github' — use to pick brand icon
iid, title, webUrl, stateIdentity
isDraftWhether the MR is in draft mode
author, assignees, createdAtAuthorship
sourceBranch, targetBranchBranch info
behindTargetnumber | null — commits behind the target branch, null when the fetch path did not ask. Informational, not a blocker
status'mergeable' | 'blocked' | 'draft' | 'merged' | 'closed'
statusDetailSpecific blocker reason (e.g., 'CI_MUST_PASS', 'NEED_REBASE')
isReadytrue when detailedMergeStatus === 'mergeable'
pipelinePipeline status breakdown: passing, failing, running counts, hasWarnings, individual jobs
reviewsReviewer state: required, given, remaining, approvedBy, per-reviewer breakdown
mergeButton{ visible, disabled, loading, label }
rebaseButton{ visible, loading, label } — visible tracks GitLab's shouldBeRebased
autoMergeButton{ visible, isActive, strategy, setBy, label, cancelLabel }
blockersFlags: isDraft, hasConflicts, needsRebase, pipelineFailing, pipelineRunning, awaitingApprovals, hasUnresolvedDiscussions, hasMergeError, any. needsRebase means GitLab requires a rebase before merge, not that the target branch moved
isMerging, isRebasing, isLoadingIn-progress spinner states
connection'connecting' | 'connected' | 'disconnected' | 'reconnecting' | 'idle'

MRDashboardActions

Pre-bound mutation methods — call without passing project path or IID:

ActionDescription
merge(input?)Merge the MR
rebase()Rebase onto target branch
approve()Approve the MR
unapprove()Remove approval
setAutoMerge()Enable auto-merge
cancelAutoMerge()Cancel auto-merge
retryPipeline(id)Retry a pipeline
requestReReview(usernames?)Request re-review
toggleDraft(draft)Toggle draft / ready-for-review status
canProvider capability flags

Real-Time Architecture

Shared WebSocket Connection

All watchMR calls on a single GitLabProvider instance share one WebSocket connection via ActionCable. Each MR subscribes 3 channels (merge status, approval state, reviewers) on the shared connection. The connection is ref-counted:

  • First watchMR call → opens the WebSocket
  • Subsequent calls → subscribe additional channels on the existing connection
  • dispose() a watcher → unsubscribes that MR's channels
  • Last watcher disposed → disconnects the WebSocket

This means watching 30 MRs uses 1 WebSocket with 90 channel subscriptions, not 30 separate connections.

createRealtimeWatcher

Generic self-healing subscription + polling module used internally by watchMR and createDashboard. Also exported for custom use:

  • Adaptive poll rate (fast when push is down, slow when healthy)
  • Full-jitter exponential backoff on fetch failures
  • Push-event debouncing (burst events → one refetch)
  • Immediate refetch on reconnect
  • Connection status reporting via WatcherStatus
import { createRealtimeWatcher } from '@mattstack/glance';
import type { WatcherStatus, WatcherSubscribeCallbacks, RealtimeWatcherOptions } from '@mattstack/glance';

Discussion & Note Mutations

The SDK exports specialized classes for working with MR discussions:

MRDetailFetcher

Fetches full MR discussion threads. Used to populate reviewer summaries and comment panels.

NoteMutator

Creates and manages notes (comments) on MRs. Returns CreatedNote with the note's metadata.

import { MRDetailFetcher, NoteMutator } from '@mattstack/glance';
import type { CreatedNote } from '@mattstack/glance';

getReviewerSummaries

Utility that merges reviewer state with discussion threads into a unified list:

import { getReviewerSummaries } from '@mattstack/glance';
import type { ReviewerSummary } from '@mattstack/glance';

const detail = await provider.fetchMRDiscussions(repoId, iid);
const summaries = getReviewerSummaries(mr, detail.discussions);
// summaries[0].reviewer, .commentCount, .discussions

Types

All domain types are exported:

import type {
  // Core domain
  PullRequest,
  PullRequestsSnapshot,
  MergeabilityCheck,
  UserRef,
  Reviewer,
  MergeRequestReviewState,
  ReviewDisplayState,
  Pipeline,
  PipelineJob,
  DiffStats,

  // Discussion
  Discussion,
  Note,
  NoteAuthor,
  NotePosition,
  MRDetail,
  ReviewerSummary,

  // Dashboard
  MRStatus,
  MRState,
  MRDashboardProps,
  MRDashboardActions,
  Dashboard,
  DashboardGroup,
  CreateDashboardOptions,

  // Provider
  GitProvider,
  FetchPullRequestsOptions,
  ProviderCapabilities,
  ProviderSlug,
  BranchProtectionRule,

  // Mutations
  CreatePullRequestInput,
  UpdatePullRequestInput,
  MergePullRequestInput,
  MergeMethod,
  CreatedNote,

  // Real-time
  RealtimeWatcherOptions,
  WatcherSubscribeCallbacks,
  WatcherStatus,
  ActionCableCallbacks,

  // Server
  FeedEvent,
  FeedSnapshot,
  ServerNotification,

  // Logger
  ForgeLogger,
} from '@mattstack/glance';

Utility Exports

ExportDescription
createProvider(slug, url, token, options?)Factory — creates the right provider by slug
SUPPORTED_PROVIDERS['gitlab', 'github'] as const
parseRepoId(id)Extract numeric ID from "gitlab:42" → 42
repoIdProvider(id)Extract provider from "gitlab:42" → "gitlab"
getReviewDisplayState(raw)Map raw review state → UI display state
getReviewerSummaries(mr, discussions)Merge reviewer state with discussion threads
noopLoggerSilent logger implementation
parseGitLabRepoId(path)Parse GitLab project path to numeric ID
MR_DASHBOARD_FRAGMENTGraphQL fragment for MR dashboard queries
stripDraftPrefix(title)Remove a GitLab draft marker (Draft:, [draft], (Draft)) from a title
draftTitle(title, draft)Compute the wire title for a target draft state, without double-prefixing
ReadBackFailedErrorThrown when a write landed but the MR could not be read back — see below

ReadBackFailedError

createPullRequest, updatePullRequest, and mergePullRequest each write, then read the MR back to return it. The write and the read are separate calls, so a read that fails says nothing about whether the write did. When the read-back cannot be completed these throw ReadBackFailedError with writeApplied: true, which means the edit is on the forge and re-issuing it would apply it twice.

import { ReadBackFailedError } from '@mattstack/glance';

try {
  await provider.updatePullRequest(path, iid, { draft: true });
} catch (err) {
  if (err instanceof ReadBackFailedError && err.writeApplied) {
    // The edit landed; only describing the result failed. Re-fetch, don't re-write.
  } else {
    throw err;
  }
}
PropertyDescription
writeAppliedtrue when a write preceded this read. false only where none did (a watcher poll)
operationSDK operation that gave up, e.g. "updatePullRequest"
projectPath, iidWhich MR the read-back was for
causeThe last underlying read failure, when there was one

It extends Error, so a caller that does not branch on it behaves exactly as before. A failed draft transition is a different case and still throws a plain Error: there the read worked and the MR genuinely is not in the requested state.

License

MIT

FAQs

Package last updated on 23 Sep 2026

Related posts