
Security News
PHP Foundation Says Chainguard Broke Security Embargo Ahead of Emergency Patches
The PHP Foundation says Chainguard broke a security embargo ahead of critical RCE patches, as leaks disrupted the coordinated release.
@mattstack/glance
Advanced tools
GitHub and GitLab API client: REST, GraphQL, and real-time ActionCable subscriptions
Provider-agnostic SDK for GitHub and GitLab: types, REST/GraphQL clients, real-time subscriptions, and dashboard helpers. Works in any Node/Bun runtime.
npm install @mattstack/glance
# or
bun add @mattstack/glance
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();
Two built-in providers implement the GitProvider interface:
| Provider | Class | Capabilities |
|---|---|---|
| GitLab | GitLabProvider | Full: merge, rebase, approve, auto-merge, retry pipeline, re-review, resolve discussions |
| GitHub | GitHubProvider | Core: 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
});
| Method | Description |
|---|---|
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.
createDashboardHigh-level factory that bundles real-time MR watching with pre-bound mutation actions.
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();
| Property | Type | Description |
|---|---|---|
actions | MRDashboardActions | Pre-bound mutation methods — available immediately |
isInitialLoading | boolean | true until the first data payload arrives |
subscribe(cb) | (cb: (mr: MRDashboardProps) => void) => void | Register a listener for real-time updates |
onStatusChange(cb) | (cb: (status: WatcherStatus) => void) => void | Monitor connection health |
dispose() | () => void | Stop the subscription and clean up |
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));
DashboardGroupPass 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));
| Property | Type | Description |
|---|---|---|
actionsFor(iid) | (iid: number) => MRDashboardActions | Get actions for a specific MR |
isInitialLoading | boolean | true until the first data payload arrives |
subscribe(cb) | (cb: (mrs: Map<number, MRDashboardProps>) => void) => void | Real-time updates for all MRs |
updateIids(iids) | (iids: number[] | (prev: number[]) => number[]) => void | Dynamically add/remove tracked MRs |
onStatusChange(cb) | (cb: (status: WatcherStatus) => void) => void | Monitor connection health |
dispose() | () => void | Stop all subscriptions and clean up |
getMRDashboardPropsLower-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'
MRDashboardPropsPre-computed, UI-ready props — no conditional logic needed in the component layer:
| Field | Description |
|---|---|
provider | 'gitlab' or 'github' — use to pick brand icon |
iid, title, webUrl, state | Identity |
isDraft | Whether the MR is in draft mode |
author, assignees, createdAt | Authorship |
sourceBranch, targetBranch | Branch info |
behindTarget | number | null — commits behind the target branch, null when the fetch path did not ask. Informational, not a blocker |
status | 'mergeable' | 'blocked' | 'draft' | 'merged' | 'closed' |
statusDetail | Specific blocker reason (e.g., 'CI_MUST_PASS', 'NEED_REBASE') |
isReady | true when detailedMergeStatus === 'mergeable' |
pipeline | Pipeline status breakdown: passing, failing, running counts, hasWarnings, individual jobs |
reviews | Reviewer 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 } |
blockers | Flags: 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, isLoading | In-progress spinner states |
connection | 'connecting' | 'connected' | 'disconnected' | 'reconnecting' | 'idle' |
MRDashboardActionsPre-bound mutation methods — call without passing project path or IID:
| Action | Description |
|---|---|
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 |
can | Provider capability flags |
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:
watchMR call → opens the WebSocketdispose() a watcher → unsubscribes that MR's channelsThis means watching 30 MRs uses 1 WebSocket with 90 channel subscriptions, not 30 separate connections.
createRealtimeWatcherGeneric self-healing subscription + polling module used internally by watchMR and createDashboard. Also exported for custom use:
WatcherStatusimport { createRealtimeWatcher } from '@mattstack/glance';
import type { WatcherStatus, WatcherSubscribeCallbacks, RealtimeWatcherOptions } from '@mattstack/glance';
The SDK exports specialized classes for working with MR discussions:
MRDetailFetcherFetches full MR discussion threads. Used to populate reviewer summaries and comment panels.
NoteMutatorCreates 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';
getReviewerSummariesUtility 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
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';
| Export | Description |
|---|---|
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 |
noopLogger | Silent logger implementation |
parseGitLabRepoId(path) | Parse GitLab project path to numeric ID |
MR_DASHBOARD_FRAGMENT | GraphQL 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 |
ReadBackFailedError | Thrown when a write landed but the MR could not be read back — see below |
ReadBackFailedErrorcreatePullRequest, 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;
}
}
| Property | Description |
|---|---|
writeApplied | true when a write preceded this read. false only where none did (a watcher poll) |
operation | SDK operation that gave up, e.g. "updatePullRequest" |
projectPath, iid | Which MR the read-back was for |
cause | The 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.
FAQs
GitHub and GitLab API client: REST, GraphQL, and real-time ActionCable subscriptions
We found that @mattstack/glance demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
The PHP Foundation says Chainguard broke a security embargo ahead of critical RCE patches, as leaks disrupted the coordinated release.

Security News
PHP releases emergency patches for two critical RCE vulnerabilities as agencies urge organizations to take unpatched sites offline.

Threat Research
/Security News
A supply chain attack compromised LiteLLM 1.107.0 on PyPI and Docker, spreading malware that steals cloud credentials, developer secrets, and AI provider keys.