Automatic size labels for Merge Requests in GitLab CI
In large projects, it's handy to sort Merge Requests by size: small changes can be reviewed quickly, while big ones need more careful attention. Instead of assigning labels manually, you can add a script to GitLab CI that automatically sets them based on the number of lines in the diff.
These labels can help with filtering, statistics, or even merge control — for example, requiring two reviews before merging especially large changes (size/XL
).
Implementation
First, you'll need a token stored in the SIZE_LABEL_JOB_TOKEN
environment variable. The token must have the following scopes:
api
read_repository
write_repository
Instead of bulky bash scripts, we'll write the logic in JavaScript and hook it into the GitLab CI pipeline.
Run the script on Merge Request
yaml# .gitlab-ci.yaml size-label: stage: labeling only: - merge_requests script: - node ci/size-label.js
Fetch changes from the Merge Request
jsconst TOKEN = process.env.SIZE_LABEL_JOB_TOKEN; const API_URL = process.env.CI_API_V4_URL; const REPO_ID = process.env.CI_PROJECT_ID; const MR_IID = process.env.CI_MERGE_REQUEST_IID; const MR_ENDPOINT = `${API_URL}/projects/${REPO_ID}/merge_requests/${MR_IID}`; async function apiRequest(url, method = "GET", body = null) { const headers = { "PRIVATE-TOKEN": TOKEN }; if (body) headers["Content-Type"] = "application/json"; const response = await fetch(url, { method, headers, body }); if (!response.ok) throw new Error(`HTTP error ${response.status}: ${await response.text()}`); return response.json(); } const mrData = await apiRequest(`${MR_ENDPOINT}/changes`);
Count added and removed lines, ignoring large files
jsconst IGNORED_FILES = ["package-lock.json"]; const diffs = mrData.changes .filter(({ new_path }) => !IGNORED_FILES.some((x) => new_path.endsWith(x))) .map(({ diff }) => diff) .join("\n"); const added = (diffs.match(/^\+[^+]/gm) ?? []).length; const removed = (diffs.match(/^-[^-]/gm) ?? []).length; const total = added + removed;
Assign a size label based on the total number of lines
jslet label = "size/XS"; if (total >= 1000) label = "size/XL"; else if (total >= 500) label = "size/L"; else if (total >= 100) label = "size/M"; else if (total >= 10) label = "size/S"; console.info(`Changes: +${added}, -${removed} → ${label}`);
Update MR labels, preserving any unrelated to size
jsconst mrMeta = await apiRequest(MR_ENDPOINT); const existingLabels = mrMeta.labels.filter((l) => !l.startsWith("size/")); const newLabels = [...existingLabels, label]; if (newLabels.every((l) => mrMeta.labels.includes(l))) { console.info("No labels update needed"); } else { await apiRequest(MR_ENDPOINT, "PUT", JSON.stringify({ labels: newLabels })); console.info(`Applied labels: ${newLabels.join(",")}`); }
Final code
// ci/size-label.js
import process from "node:process";
const TOKEN = process.env.SIZE_LABEL_JOB_TOKEN;
const API_URL = process.env.CI_API_V4_URL;
const REPO_ID = process.env.CI_PROJECT_ID;
const MR_IID = process.env.CI_MERGE_REQUEST_IID;
const MR_ENDPOINT = `${API_URL}/projects/${REPO_ID}/merge_requests/${MR_IID}`;
const IGNORED_FILES = ["package-lock.json"];
async function apiRequest(url, method = "GET", body = null) {
const headers = { "PRIVATE-TOKEN": TOKEN };
if (body) headers["Content-Type"] = "application/json";
const response = await fetch(url, { method, headers, body });
if (!response.ok) throw new Error(`HTTP error ${response.status}: ${await response.text()}`);
return response.json();
}
async function main() {
try {
const mrData = await apiRequest(`${MR_ENDPOINT}/changes`);
const diffs = mrData.changes
.filter(({ new_path }) => !IGNORED_FILES.some((x) => new_path.endsWith(x)))
.map(({ diff }) => diff)
.join("\n");
const added = (diffs.match(/^\+[^+]/gm) ?? []).length;
const removed = (diffs.match(/^-[^-]/gm) ?? []).length;
const total = added + removed;
let label = "size/XS";
if (total >= 1000) label = "size/XL";
else if (total >= 500) label = "size/L";
else if (total >= 100) label = "size/M";
else if (total >= 10) label = "size/S";
console.info(`Changes: +${added}, -${removed} → ${label}`);
const mrMeta = await apiRequest(MR_ENDPOINT);
const existingLabels = mrMeta.labels.filter((l) => !l.startsWith("size/"));
const newLabels = [...existingLabels, label];
if (newLabels.every((l) => mrMeta.labels.includes(l))) {
console.info("No labels update needed");
} else {
await apiRequest(MR_ENDPOINT, "PUT", JSON.stringify({ labels: newLabels }));
console.info(`Applied labels: ${newLabels.join(",")}`);
}
} catch (error) {
console.error("Error applying label:", error.message ?? error);
process.exit(1);
}
}
main();
Conclusion
We now have a simple script, making it easier to assess the scale of large MR flows at a glance. Small ones (size/XS
) can be accepted right away, medium ones (size/M
) reviewed over a cup of tea, and large ones (size/XL
) best avoided late on a Friday evening.