Skip to content

Instantly share code, notes, and snippets.

@Wxh16144
Last active February 12, 2026 10:09
Show Gist options
  • Select an option

  • Save Wxh16144/bd63cb3789be02c088a781bb03b73cf6 to your computer and use it in GitHub Desktop.

Select an option

Save Wxh16144/bd63cb3789be02c088a781bb03b73cf6 to your computer and use it in GitHub Desktop.
GitLab 贡献热力图生成器 (Contribution Graph)
/**
* GitLab 贡献热力图生成器 (Contribution Graph)
*
* 1. 必填配置:修改下方 `GITLAB_CONFIG` (GitLab地址, Token, 目标用户)。
* 2. 运行脚本:node gitlab-contribution.js
* // or envx run -f .env -- node gitlab-contribution.js
*/
const http = require('http');
/**
* ================= CONFIGURATION =================
* 请在这里配置您的 GitLab 信息
*/
const GITLAB_CONFIG = {
baseUrl: process.env.GITLAB_BASE_URL || 'https://gitlab.com', // 替换为您的 GitLab 实例地址
token: process.env.GITLAB_TOKEN, // 替换为您的 Access Token
targetUser: '54', // 想要查询的用户名或ID
excludeWeekend: false, // 是否排除周末
weekStart: 1, // 0 = 周日起始, 1 = 周一起始
dateRange: {
// 入职日期
start: '2023-04-17',
// 默认到今天
end: new Date().toISOString().split('T')[0]
}
};
/**
* 获取用户 ID
*/
async function getUserId(username) {
if (!isNaN(username)) return username;
const apiUrl = `${GITLAB_CONFIG.baseUrl}/api/v4/users?username=${username}`;
try {
const res = await fetch(apiUrl, { headers: { 'PRIVATE-TOKEN': GITLAB_CONFIG.token } });
const data = await res.json();
if (data && data.length > 0) return data[0].id;
throw new Error(`User ${username} not found`);
} catch (error) {
console.error('Error fetching user ID:', error);
process.exit(1);
}
}
/**
* 获取贡献数据
*/
async function fetchContributions(userId, afterDate, beforeDate) {
let allEvents = [];
let page = 1;
const perPage = 100;
console.log(`Fetching events for user ${userId} from ${afterDate} to ${beforeDate}...`);
while (true) {
const apiUrl = `${GITLAB_CONFIG.baseUrl}/api/v4/users/${userId}/events?after=${afterDate}&before=${beforeDate}&per_page=${perPage}&page=${page}`;
try {
const res = await fetch(apiUrl, { headers: { 'PRIVATE-TOKEN': GITLAB_CONFIG.token } });
if (!res.ok) throw new Error(`API Error: ${res.statusText}`);
const events = await res.json();
if (events.length === 0) break;
allEvents = allEvents.concat(events);
console.log(` Fetched page ${page}, total so far: ${allEvents.length}`);
if (events.length < perPage) break;
page++;
} catch (error) {
console.error('Error fetching events:', error);
break;
}
}
return allEvents;
}
/**
* 处理数据
*/
function processEvents(events) {
const contributions = {};
events.forEach(event => {
const dateStr = event.created_at.split('T')[0];
if (['push', 'issue', 'merge_request', 'comment'].includes(event.action_name) || true) {
contributions[dateStr] = (contributions[dateStr] || 0) + 1;
}
});
return contributions;
}
/**
* 生成 HTML 页面
*/
function generateHtml(contributions, startStr, endStr, weekStart = 0) {
const startDate = new Date(startStr);
const endDate = new Date(endStr);
let currentDate = new Date(startDate);
// 颜色配置
const levels = [
{ min: 0, color: '#ebedf0' },
{ min: 1, color: '#9be9a8' },
{ min: 4, color: '#40c463' },
{ min: 7, color: '#30a14e' },
{ min: 10, color: '#216e39' }
];
const getColor = (count) => {
if (!count) return levels[0].color;
if (count >= 10) return levels[4].color;
if (count >= 7) return levels[3].color;
if (count >= 4) return levels[2].color;
return levels[1].color;
};
// 1. 生成扁平的天列表
const dayCells = [];
while (currentDate <= endDate) {
const isoDate = currentDate.toISOString().split('T')[0];
const count = contributions[isoDate] || 0;
dayCells.push({ date: isoDate, count, color: getColor(count), dayOfWeek: currentDate.getDay() });
currentDate.setDate(currentDate.getDate() + 1);
}
// 2. 根据 weekStart 重新组织为周 (gridWeeks)
const getAdjustedIndex = (dayIndex) => (dayIndex - weekStart + 7) % 7;
const gridWeeks = [];
let currentWeek = new Array(7).fill(null);
// 填充起始空白
if (dayCells.length > 0) {
const firstAdj = getAdjustedIndex(dayCells[0].dayOfWeek);
for (let i = 0; i < firstAdj; i++) currentWeek[i] = { color: 'transparent', count: 0, date: null };
}
dayCells.forEach(day => {
const adjIndex = getAdjustedIndex(day.dayOfWeek);
currentWeek[adjIndex] = day;
if (adjIndex === 6) {
gridWeeks.push(currentWeek);
currentWeek = new Array(7).fill(null);
}
});
// 填充末尾空白
if (currentWeek.some(d => d !== null)) {
for (let i = 0; i < 7; i++) if (!currentWeek[i]) currentWeek[i] = { color: 'transparent', count: 0, date: null };
gridWeeks.push(currentWeek);
}
// 3. 构建 HTML 字符串 (同步生成 Weeks 和 Months 部分,处理分割线)
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
let weeksHtml = '';
let monthsHtml = '';
let lastYear = null;
let lastMonth = -1;
gridWeeks.forEach((week) => {
// 找到这一周主要属于哪一年/哪个月 (取第一个有效日期)
const validDay = week.find(d => d && d.date);
let currentYear = null;
let currentMonth = null;
if (validDay) {
const d = new Date(validDay.date);
currentYear = d.getFullYear();
currentMonth = d.getMonth();
}
// --- 年份分割线逻辑 ---
// 如果年份发生了变化,且不是第一年,插入分割线
if (lastYear !== null && currentYear !== null && currentYear !== lastYear) {
weeksHtml += `<div class="year-divider"></div>`;
// 为了保持对齐,Months 行也必须插入一个同样宽度的(透明)分割占位
monthsHtml += `<div class="year-divider"></div>`;
}
if (currentYear !== null) lastYear = currentYear;
// --- 热力图列 ---
weeksHtml += `<div class="week-column">
${week.map(day => `
<div class="day-cell"
style="background-color: ${day.color}"
title="${day.date || ''}: ${day.count} contributions"></div>
`).join('')}
</div>`;
// --- 月份标签列 ---
let label = '';
if (currentMonth !== null) {
if (currentMonth !== lastMonth) {
label = monthNames[currentMonth];
lastMonth = currentMonth;
}
}
monthsHtml += `<div class="month-cell">${label}</div>`;
});
return `
<!DOCTYPE html>
<html>
<head>
<title>GitLab Contribution Graph</title>
<style>
body { font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; display: flex; justify-content: center; padding-top: 50px; background: #f6f8fa; color: #24292f; }
.graph-container { background: #fff; padding: 20px; border: 1px solid #d0d7de; border-radius: 6px; overflow-x: auto; max-width: 95vw; }
.weeks, .months { display: flex; gap: 3px; }
.week-column { display: flex; flex-direction: column; gap: 3px; }
.day-cell { width: 10px; height: 10px; border-radius: 2px; }
.day-cell:hover { outline: 1px solid rgba(0,0,0,0.5); cursor: pointer; }
/* 月份标签样式 */
.months { margin-top: 5px; height: 15px; }
.month-cell { width: 10px; font-size: 10px; color: #768390; overflow: visible; white-space: nowrap; line-height: 1; }
/* 年份分割线样式 */
.year-divider {
width: 1px;
background-color: #d0d7de; /* 灰色线 */
margin: 0 2px; /* 分割线左右留白 */
flex-shrink: 0;
border-radius: 1px;
}
/* 月份区域的分割线设为透明,仅占位用 */
.months .year-divider { background-color: transparent; }
h1 { text-align: center; margin-bottom: 20px; }
.legend { display: flex; align-items: center; gap: 4px; font-size: 12px; margin-top: 15px; color: #57606a; justify-content: flex-end; }
</style>
</head>
<body>
<div>
<h1>Contributions (${startStr} ~ ${endStr})</h1>
<div class="graph-container">
<div class="weeks">${weeksHtml}</div>
<div class="months">${monthsHtml}</div>
<div class="legend">
Less
<div class="day-cell" style="background-color: ${levels[0].color}"></div>
<div class="day-cell" style="background-color: ${levels[1].color}"></div>
<div class="day-cell" style="background-color: ${levels[2].color}"></div>
<div class="day-cell" style="background-color: ${levels[3].color}"></div>
<div class="day-cell" style="background-color: ${levels[4].color}"></div>
More
</div>
</div>
</div>
</body>
</html>
`;
}
/**
* 主程序
*/
async function main() {
console.log('Starting GitLab Contribution fetcher...');
if (GITLAB_CONFIG.baseUrl.includes('example.com')) {
console.error('ERROR: Please configure GITLAB_CONFIG first.');
process.exit(1);
}
const userId = await getUserId(GITLAB_CONFIG.targetUser);
console.log(`Resolved User ID: ${userId}`);
const { start: startStr, end: endStr } = GITLAB_CONFIG.dateRange;
const weekStart = GITLAB_CONFIG.weekStart !== undefined ? GITLAB_CONFIG.weekStart : 0;
const events = await fetchContributions(userId, startStr, endStr);
const contributions = processEvents(events);
const total = Object.values(contributions).reduce((a, b) => a + b, 0);
console.log(`Total contributions: ${total}`);
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(generateHtml(contributions, startStr, endStr, weekStart));
});
const PORT = 3000;
server.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}/`);
});
}
// 直接运行时执行
main();
@Wxh16144
Copy link
Author

GitLab Contribution Graph Generator

轻量级的 Node.js 脚本,用于连接(自部署)GitLab 实例,获取指定用户的贡献数据,并在本地生成类似 GitHub 的贡献热力图(Contribution Heatmap)。

company-gitLab-contributions

✨ 主要功能

  • 私有部署支持:完美支持自托管(Self-hosted)的 GitLab 实例。
  • 按需统计:支持自定义统计的起止日期范围,不仅限于当前年份。
  • 无需依赖:基于 Node.js 原生 HTTP 和 Fetch API,通常无需安装 node_modules(Node.js 18+ 环境下)。
  • 自动解析:输入用户名即可自动查找 User ID。

🚀 快速开始

方式一:直接下载并运行 (推荐)

  1. 下载脚本

    curl -o gitlab-contribution.js https://gist.githubusercontent.com/Wxh16144/bd63cb3789be02c088a781bb03b73cf6/raw/gitlab.contribution.js
  2. 配置与运行
    您可以直接编辑文件顶部的 GITLAB_CONFIG,或者使用环境变量运行(无需修改文件):

    # 使用环境变量运行 (适用于 Node 18+)
    export GITLAB_TOKEN="您的Personal_Access_Token"
    export GITLAB_BASE_URL="https://gitlab.your-company.com"
    
    # 运行脚本
    node gitlab-contribution.js

方式二:手动配置

打开脚本文件 gitlab-contribution.js,修改顶部的配置对象:

const GITLAB_CONFIG = {
  baseUrl: 'https://git.company.com', // 您的 GitLab 地址
  token: 'glpat-xxxxxxxxxxxx',        // 您的 Access Token
  targetUser: 'wuxh',                 // 查询的用户名
  dateRange: {
    start: '2023-01-01',              // 开始日期
    end: '2023-12-31'                 // 结束日期
  }
};

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment