Skip to main content
Glama
ttpears

GitLab MCP Server

by ttpears

Review Bottlenecks

analytics_review_bottlenecks
Read-onlyIdempotent

Identify review bottlenecks by aggregating open merge requests across a group or project. Returns per-reviewer queue stats, age histograms, and the stalest MRs by update time.

Instructions

Aggregate open (non-draft) merge requests across a group or project to surface review bottlenecks. Returns per-reviewer queue stats, age-bucket histogram, and the stalest MRs by updatedAt. MRs with no reviewer assigned are bucketed under "(unassigned)".

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
groupNoGroup full path (e.g. "my-group"). Mutually exclusive with `project`.
projectNoProject full path (e.g. "my-group/my-project"). Mutually exclusive with `group`.
staleAfterDaysNoAn MR counts as "stale" if its updatedAt is older than this many days. Default 3.
topStalestNoCap on the stalest[] list. Default 20.
maxMRsNoHard cap on MRs scanned. Envelope flags `truncated:true` if reached.
userCredentialsNoYour GitLab credentials (optional — falls back to the configured env token if not provided)

Implementation Reference

  • src/tools.ts:2134-2270 (registration)
    The full tool definition for 'analytics_review_bottlenecks'. This is a Tool object with name 'analytics_review_bottlenecks', title 'Review Bottlenecks', description, inputSchema, annotations, and the handler function that executes the review bottleneck analysis logic.
    const analyticsReviewBottlenecksTool: Tool = {
      name: 'analytics_review_bottlenecks',
      title: 'Review Bottlenecks',
      description:
        'Aggregate open (non-draft) merge requests across a group or project to surface review bottlenecks. Returns per-reviewer queue stats, age-bucket histogram, and the stalest MRs by updatedAt. MRs with no reviewer assigned are bucketed under "(unassigned)".',
      requiresAuth: false,
      requiresWrite: false,
      annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
      inputSchema: withUserAuth(z.object({
        group: z.string().optional().describe('Group full path (e.g. "my-group"). Mutually exclusive with `project`.'),
        project: z.string().optional().describe('Project full path (e.g. "my-group/my-project"). Mutually exclusive with `group`.'),
        staleAfterDays: z.number().int().min(0).default(3).describe('An MR counts as "stale" if its updatedAt is older than this many days. Default 3.'),
        topStalest: z.number().int().min(1).max(200).default(20).describe('Cap on the stalest[] list. Default 20.'),
        maxMRs: z.number().int().min(1).max(5000).default(1000).describe('Hard cap on MRs scanned. Envelope flags `truncated:true` if reached.'),
      })),
      handler: async (input, client, userConfig) => {
        const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
        const group = input.group?.trim();
        const project = input.project?.trim();
        if ((!group && !project) || (group && project)) {
          throw new Error('analytics_review_bottlenecks requires exactly one of `group` or `project`.');
        }
        const scope: { type: 'group' | 'project'; fullPath: string } = group
          ? { type: 'group', fullPath: group }
          : { type: 'project', fullPath: project! };
    
        const page = await client.listOpenMergeRequestsForReviewQueue(
          scope,
          { maxItems: input.maxMRs },
          credentials,
        );
        const truncated = !!page.hasMore;
        const rawNodes: any[] = Array.isArray(page.nodes) ? page.nodes : [];
        const mrs = rawNodes.filter((mr) => !mr.draft);
    
        const now = Date.now();
        const staleAfterMs = input.staleAfterDays * 86400000;
    
        type EnrichedMR = {
          projectFullPath: string;
          iid: string;
          title: string;
          webUrl: string;
          author: string | null;
          reviewers: string[];
          ageDays: number;
          ageMs: number;
          updatedAt: string;
          bucket: ReviewBucket;
        };
    
        const enriched: EnrichedMR[] = mrs.map((mr) => {
          const updatedAtStr: string = mr.updatedAt;
          const ageMs = now - new Date(updatedAtStr).getTime();
          const ageDays = Math.floor(ageMs / 86400000);
          const reviewers: string[] = Array.isArray(mr.reviewers?.nodes)
            ? mr.reviewers.nodes.map((r: any) => r?.username).filter(Boolean)
            : [];
          const projectFullPath: string =
            mr.project?.fullPath ?? (scope.type === 'project' ? scope.fullPath : '');
          return {
            projectFullPath,
            iid: String(mr.iid),
            title: mr.title,
            webUrl: mr.webUrl,
            author: mr.author?.username ?? null,
            reviewers,
            ageDays,
            ageMs,
            updatedAt: updatedAtStr,
            bucket: bucketForAge(ageDays),
          };
        });
    
        const totals = {
          mrs: enriched.length,
          stale: enriched.filter((m) => m.ageMs >= staleAfterMs).length,
          unassigned: enriched.filter((m) => m.reviewers.length === 0).length,
        };
    
        const buckets = emptyReviewBuckets();
        for (const m of enriched) buckets[m.bucket]++;
    
        type ReviewerAgg = {
          reviewer: string;
          total: number;
          stale: number;
          oldestAgeDays: number;
          buckets: Record<ReviewBucket, number>;
        };
        const reviewerMap = new Map<string, ReviewerAgg>();
        for (const m of enriched) {
          const keys = m.reviewers.length ? m.reviewers : ['(unassigned)'];
          for (const key of keys) {
            let agg = reviewerMap.get(key);
            if (!agg) {
              agg = { reviewer: key, total: 0, stale: 0, oldestAgeDays: 0, buckets: emptyReviewBuckets() };
              reviewerMap.set(key, agg);
            }
            agg.total++;
            if (m.ageMs >= staleAfterMs) agg.stale++;
            if (m.ageDays > agg.oldestAgeDays) agg.oldestAgeDays = m.ageDays;
            agg.buckets[m.bucket]++;
          }
        }
        const byReviewer = Array.from(reviewerMap.values()).sort((a, b) => {
          if (b.stale !== a.stale) return b.stale - a.stale;
          return b.total - a.total;
        });
    
        const stalest = enriched
          .slice()
          .sort((a, b) => b.ageMs - a.ageMs)
          .slice(0, input.topStalest)
          .map((m) => ({
            projectFullPath: m.projectFullPath,
            iid: m.iid,
            title: m.title,
            webUrl: m.webUrl,
            author: m.author,
            reviewers: m.reviewers,
            ageDays: m.ageDays,
            updatedAt: m.updatedAt,
          }));
    
        return {
          scope: { type: scope.type, fullPath: scope.fullPath },
          generatedAt: new Date().toISOString(),
          staleAfterDays: input.staleAfterDays,
          totals,
          buckets,
          byReviewer,
          stalest,
          truncated,
        };
      },
    };
  • The handler function that executes the analytics_review_bottlenecks tool logic. It fetches open non-draft merge requests for a group or project, calculates age buckets, reviewer queue stats, and stalest MRs.
      handler: async (input, client, userConfig) => {
        const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
        const group = input.group?.trim();
        const project = input.project?.trim();
        if ((!group && !project) || (group && project)) {
          throw new Error('analytics_review_bottlenecks requires exactly one of `group` or `project`.');
        }
        const scope: { type: 'group' | 'project'; fullPath: string } = group
          ? { type: 'group', fullPath: group }
          : { type: 'project', fullPath: project! };
    
        const page = await client.listOpenMergeRequestsForReviewQueue(
          scope,
          { maxItems: input.maxMRs },
          credentials,
        );
        const truncated = !!page.hasMore;
        const rawNodes: any[] = Array.isArray(page.nodes) ? page.nodes : [];
        const mrs = rawNodes.filter((mr) => !mr.draft);
    
        const now = Date.now();
        const staleAfterMs = input.staleAfterDays * 86400000;
    
        type EnrichedMR = {
          projectFullPath: string;
          iid: string;
          title: string;
          webUrl: string;
          author: string | null;
          reviewers: string[];
          ageDays: number;
          ageMs: number;
          updatedAt: string;
          bucket: ReviewBucket;
        };
    
        const enriched: EnrichedMR[] = mrs.map((mr) => {
          const updatedAtStr: string = mr.updatedAt;
          const ageMs = now - new Date(updatedAtStr).getTime();
          const ageDays = Math.floor(ageMs / 86400000);
          const reviewers: string[] = Array.isArray(mr.reviewers?.nodes)
            ? mr.reviewers.nodes.map((r: any) => r?.username).filter(Boolean)
            : [];
          const projectFullPath: string =
            mr.project?.fullPath ?? (scope.type === 'project' ? scope.fullPath : '');
          return {
            projectFullPath,
            iid: String(mr.iid),
            title: mr.title,
            webUrl: mr.webUrl,
            author: mr.author?.username ?? null,
            reviewers,
            ageDays,
            ageMs,
            updatedAt: updatedAtStr,
            bucket: bucketForAge(ageDays),
          };
        });
    
        const totals = {
          mrs: enriched.length,
          stale: enriched.filter((m) => m.ageMs >= staleAfterMs).length,
          unassigned: enriched.filter((m) => m.reviewers.length === 0).length,
        };
    
        const buckets = emptyReviewBuckets();
        for (const m of enriched) buckets[m.bucket]++;
    
        type ReviewerAgg = {
          reviewer: string;
          total: number;
          stale: number;
          oldestAgeDays: number;
          buckets: Record<ReviewBucket, number>;
        };
        const reviewerMap = new Map<string, ReviewerAgg>();
        for (const m of enriched) {
          const keys = m.reviewers.length ? m.reviewers : ['(unassigned)'];
          for (const key of keys) {
            let agg = reviewerMap.get(key);
            if (!agg) {
              agg = { reviewer: key, total: 0, stale: 0, oldestAgeDays: 0, buckets: emptyReviewBuckets() };
              reviewerMap.set(key, agg);
            }
            agg.total++;
            if (m.ageMs >= staleAfterMs) agg.stale++;
            if (m.ageDays > agg.oldestAgeDays) agg.oldestAgeDays = m.ageDays;
            agg.buckets[m.bucket]++;
          }
        }
        const byReviewer = Array.from(reviewerMap.values()).sort((a, b) => {
          if (b.stale !== a.stale) return b.stale - a.stale;
          return b.total - a.total;
        });
    
        const stalest = enriched
          .slice()
          .sort((a, b) => b.ageMs - a.ageMs)
          .slice(0, input.topStalest)
          .map((m) => ({
            projectFullPath: m.projectFullPath,
            iid: m.iid,
            title: m.title,
            webUrl: m.webUrl,
            author: m.author,
            reviewers: m.reviewers,
            ageDays: m.ageDays,
            updatedAt: m.updatedAt,
          }));
    
        return {
          scope: { type: scope.type, fullPath: scope.fullPath },
          generatedAt: new Date().toISOString(),
          staleAfterDays: input.staleAfterDays,
          totals,
          buckets,
          byReviewer,
          stalest,
          truncated,
        };
      },
    };
  • Input schema for analytics_review_bottlenecks using Zod validation. Defines parameters: group (mutually exclusive with project), project, staleAfterDays (default 3), topStalest (default 20), maxMRs (default 1000).
    inputSchema: withUserAuth(z.object({
      group: z.string().optional().describe('Group full path (e.g. "my-group"). Mutually exclusive with `project`.'),
      project: z.string().optional().describe('Project full path (e.g. "my-group/my-project"). Mutually exclusive with `group`.'),
      staleAfterDays: z.number().int().min(0).default(3).describe('An MR counts as "stale" if its updatedAt is older than this many days. Default 3.'),
      topStalest: z.number().int().min(1).max(200).default(20).describe('Cap on the stalest[] list. Default 20.'),
      maxMRs: z.number().int().min(1).max(5000).default(1000).describe('Hard cap on MRs scanned. Envelope flags `truncated:true` if reached.'),
    })),
  • Helper types and functions for review bottleneck analysis: REVIEW_BUCKETS constant, ReviewBucket type, emptyReviewBuckets() and bucketForAge() helper functions used by the analytics_review_bottlenecks tool.
    const REVIEW_BUCKETS = ['<1d', '1-3d', '3-7d', '7-14d', '>14d'] as const;
    type ReviewBucket = typeof REVIEW_BUCKETS[number];
    
    function emptyReviewBuckets(): Record<ReviewBucket, number> {
      return { '<1d': 0, '1-3d': 0, '3-7d': 0, '7-14d': 0, '>14d': 0 };
    }
    
    function bucketForAge(ageDays: number): ReviewBucket {
      if (ageDays < 1) return '<1d';
      if (ageDays < 3) return '1-3d';
      if (ageDays < 7) return '3-7d';
      if (ageDays < 14) return '7-14d';
      return '>14d';
    }
  • src/tools.ts:2299-2299 (registration)
    The analyticsReviewBottlenecksTool is included in the readOnlyTools array (line 2299), which is then spread into the main tools array (line 2336), registering it for use via the MCP server.
    analyticsReviewBottlenecksTool,
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations declare read-only and idempotent; description adds that it aggregates non-draft MRs, buckets unassigned MRs under '(unassigned)', and notes the truncated flag via maxMRs. This provides useful context beyond annotations.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Three sentences, front-loaded with main purpose, no wasted words. Efficiently communicates scope, output, and a special case (unassigned).

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

No output schema, but description explains the three output components (queue stats, histogram, stalest MRs) and mentions the truncated flag. Covers main aspects, though exact structure of histogram/queue stats is unspecified.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 100%, so baseline is 3. The description does not add meaningful parameter details beyond what the schema already provides (e.g., group/project mutual exclusivity, defaults).

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states it aggregates open MRs to surface review bottlenecks, distinguishing it from siblings like get_merge_requests. It specifies scope (group or project) and outputs (queue stats, histogram, stalest MRs).

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description implies usage for bottleneck analysis but does not explicitly state when to use this tool vs alternatives like get_merge_requests. No when-not-to-use guidance or exclusion criteria.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ttpears/gitlab-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server