拿捏年终总结:自动提取GitLab提交记录
2026年1月12日 10:29
一、脚本功能概述
这是一个用于自动提取GitLab提交记录的Node.js脚本,专为年终总结设计。它可以:
- 根据指定的时间范围批量获取GitLab提交记录
- 过滤掉合并提交,只保留实际代码变更
- 按项目分组展示提交记录
- 生成Markdown格式的提交汇总报告
二、核心模块解析
1. 环境变量读取模块
javascript
function readEnvFile(envPath) {
const content = fs.readFileSync(envPath, 'utf8');
const lines = content.split(/\r?\n/).filter(Boolean);
const env = {};
for (const line of lines) {
if (line.trim().startsWith('#')) continue;
const idx = line.indexOf('=');
if (idx === -1) continue;
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
env[key] = value;
}
return env;
}
功能说明:读取.env配置文件,解析为键值对。
配置说明:
env
# GitLab服务器地址
GITLAB_URL=https://your.gitlab.server.com
# GitLab访问令牌(从GitLab个人设置中获取)
GITLAB_TOKEN=your_gitlab_access_token
# 可选:作者用户名(用于过滤提交)
GITLAB_AUTHOR_USERNAME=your_username
# 可选:指定项目ID(多个用逗号分隔)
GITLAB_PROJECT_IDS=123,456,789
2. 命令行参数解析模块
javascript
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg.startsWith('--')) {
const [k, v] = arg.split('=');
args[k.slice(2)] = v;
}
}
return args;
}
功能说明:解析命令行参数,支持--since和--until参数。
3. 时间范围处理模块
javascript
function ensureIsoRange(sinceInput, untilInput) {
const sinceIsDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(sinceInput);
const untilIsDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(untilInput);
if (sinceIsDateOnly && untilIsDateOnly) {
const { since } = toIsoRangeDayStartEnd(sinceInput);
const { until } = toIsoRangeDayStartEnd(untilInput);
return { since, until };
}
const since = new Date(sinceInput).toISOString();
const until = new Date(untilInput).toISOString();
return { since, until };
}
功能说明:将用户输入的时间范围转换为ISO标准格式,支持日期格式和完整时间格式。
4. API请求模块
javascript
function requestJson(urlStr, headers = {}) {
return new Promise((resolve, reject) => {
const u = new URL(urlStr);
const { protocol, hostname, port, pathname, search } = u;
const lib = protocol === 'https:' ? https : http;
const options = {
hostname,
port: port || (protocol === 'https:' ? 443 : 80),
path: `${pathname}${search}`,
method: 'GET',
headers,
};
const req = lib.request(options, (res) => {
const { statusCode, headers: resHeaders } = res;
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
if (statusCode >= 200 && statusCode < 300) {
try {
const json = JSON.parse(body);
resolve({ json, headers: resHeaders, statusCode });
} catch (e) {
reject(new Error(`Invalid JSON ${statusCode}: ${body.slice(0, 200)}`));
}
} else {
reject(new Error(`HTTP ${statusCode}: ${body.slice(0, 200)}`));
}
});
});
req.on('error', reject);
req.end();
});
}
功能说明:发送HTTP/HTTPS请求,返回JSON格式的响应。
5. GitLab API调用模块
javascript
async function fetchAllCommits(baseUrl, token, id, since, until, author) {
const collected = [];
let page = 1;
for (;;) {
const params = { since, until, per_page: 100, page, with_stats: false, author };
const { commits, nextPage } = await fetchCommitsPage(baseUrl, token, id, params);
collected.push(...commits);
if (!nextPage) break;
page = parseInt(nextPage, 10);
if (!Number.isFinite(page) || page <= 0) break;
}
return collected;
}
功能说明:分页获取GitLab提交记录,支持作者过滤。
6. 提交记录过滤模块
javascript
function filterNonMerge(commits) {
const filtered = [];
for (const commit of commits) {
const { parent_ids } = commit;
const nonMerge = Array.isArray(parent_ids) ? parent_ids.length <= 1 : true;
if (nonMerge) filtered.push(commit);
}
return filtered;
}
功能说明:过滤掉合并提交,只保留实际代码变更的提交。
7. 报告生成模块
javascript
function buildMarkdown(range, author, grouped) {
const { since, until } = range;
const { username, name } = author;
const lines = [];
lines.push(`# 提交汇总`);
lines.push(`- 作者: ${name || username || ''}`);
lines.push(`- 时间范围: ${since} 至 ${until}`);
for (const project of grouped.projects) {
const { name: projName } = project.meta;
lines.push(`\n项目: ${projName}`);
const commits = project.commits;
for (const commit of commits) {
lines.push(formatCommitLine(project.meta, commit));
}
}
return `${lines.join('\n')}\n`;
}
功能说明:生成Markdown格式的提交汇总报告。
三、使用方法
-
安装依赖:无需额外依赖,使用Node.js内置模块。
-
配置.env文件:根据实际情况修改.env文件中的配置。
-
运行脚本:
bash
node fetch_commits.js --since=2025-01-01 --until=2025-12-31 node fetch_commits.js --since=2025-06-01 --until=2026-01-11 --author=你的提交用户名 -
查看报告:脚本会生成commits.md文件,包含指定时间范围内的提交记录。
四、完整代码 同级创建.env即可使用
javascript
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
function readEnvFile(envPath) {
const content = fs.readFileSync(envPath, 'utf8');
const lines = content.split(/\r?\n/).filter(Boolean);
const env = {};
for (const line of lines) {
if (line.trim().startsWith('#')) continue;
const idx = line.indexOf('=');
if (idx === -1) continue;
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
env[key] = value;
}
return env;
}
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg.startsWith('--')) {
const [k, v] = arg.split('=');
args[k.slice(2)] = v;
}
}
return args;
}
function toIsoRangeDayStartEnd(dateStr) {
const start = new Date(`${dateStr}T00:00:00.000Z`);
const end = new Date(`${dateStr}T23:59:59.999Z`);
return { since: start.toISOString(), until: end.toISOString() };
}
function ensureIsoRange(sinceInput, untilInput) {
const sinceIsDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(sinceInput);
const untilIsDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(untilInput);
if (sinceIsDateOnly && untilIsDateOnly) {
const { since } = toIsoRangeDayStartEnd(sinceInput);
const { until } = toIsoRangeDayStartEnd(untilInput);
return { since, until };
}
const since = new Date(sinceInput).toISOString();
const until = new Date(untilInput).toISOString();
return { since, until };
}
function requestJson(urlStr, headers = {}) {
return new Promise((resolve, reject) => {
const u = new URL(urlStr);
const { protocol, hostname, port, pathname, search } = u;
const lib = protocol === 'https:' ? https : http;
const options = {
hostname,
port: port || (protocol === 'https:' ? 443 : 80),
path: `${pathname}${search}`,
method: 'GET',
headers,
};
const req = lib.request(options, (res) => {
const { statusCode, headers: resHeaders } = res;
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
if (statusCode >= 200 && statusCode < 300) {
try {
const json = JSON.parse(body);
resolve({ json, headers: resHeaders, statusCode });
} catch (e) {
reject(new Error(`Invalid JSON ${statusCode}: ${body.slice(0, 200)}`));
}
} else {
reject(new Error(`HTTP ${statusCode}: ${body.slice(0, 200)}`));
}
});
});
req.on('error', reject);
req.end();
});
}
function buildApiUrl(base, pathStr, query = {}) {
const u = new URL(pathStr, base);
const entries = Object.entries(query).filter(([, v]) => v !== undefined && v !== null);
for (const [k, v] of entries) {
u.searchParams.set(k, String(v));
}
return u.toString();
}
async function fetchProjectMeta(baseUrl, token, id) {
const url = buildApiUrl(baseUrl, `/api/v4/projects/${encodeURIComponent(id)}`);
const headers = { 'PRIVATE-TOKEN': token };
const { json } = await requestJson(url, headers);
const { name, path_with_namespace, web_url } = json;
return { id, name, path_with_namespace, web_url };
}
async function fetchCommitsPage(baseUrl, token, id, params) {
const url = buildApiUrl(
baseUrl,
`/api/v4/projects/${encodeURIComponent(id)}/repository/commits`,
params
);
const headers = { 'PRIVATE-TOKEN': token };
const { json, headers: resHeaders } = await requestJson(url, headers);
const { ['x-next-page']: nextPage, ['x-page']: page, ['x-total-pages']: totalPages } = resHeaders;
return { commits: json, nextPage, page, totalPages };
}
async function fetchAllCommits(baseUrl, token, id, since, until, author) {
const collected = [];
let page = 1;
for (;;) {
const params = { since, until, per_page: 100, page, with_stats: false, author };
const { commits, nextPage } = await fetchCommitsPage(baseUrl, token, id, params);
collected.push(...commits);
if (!nextPage) break;
page = parseInt(nextPage, 10);
if (!Number.isFinite(page) || page <= 0) break;
}
return collected;
}
function filterNonMerge(commits) {
const filtered = [];
for (const commit of commits) {
const { parent_ids } = commit;
const nonMerge = Array.isArray(parent_ids) ? parent_ids.length <= 1 : true;
if (nonMerge) filtered.push(commit);
}
return filtered;
}
function formatCommitLine(project, commit) {
const { short_id, title, message, committed_date, author_name, author_email } = commit;
const main = (title || message || '').replace(/\r?\n/g, ' ');
const ts = formatDateLocal(committed_date);
return `- ${ts} | ${short_id} | ${main} | ${author_name} <${author_email}>`;
}
function pad2(n) {
return String(n).padStart(2, '0');
}
function formatDateLocal(iso) {
const d = new Date(iso);
const y = d.getFullYear();
const m = pad2(d.getMonth() + 1);
const day = pad2(d.getDate());
const hh = pad2(d.getHours());
const mm = pad2(d.getMinutes());
const ss = pad2(d.getSeconds());
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`;
}
function buildMarkdown(range, author, grouped) {
const { since, until } = range;
const { username, name } = author;
const lines = [];
lines.push(`# 提交汇总`);
lines.push(`- 作者: ${name || username || ''}`);
lines.push(`- 时间范围: ${since} 至 ${until}`);
for (const project of grouped.projects) {
const { name: projName } = project.meta;
lines.push(`\n项目: ${projName}`);
const commits = project.commits;
for (const commit of commits) {
lines.push(formatCommitLine(project.meta, commit));
}
}
return `${lines.join('\n')}\n`;
}
async function fetchMembershipProjects(baseUrl, token) {
const headers = { 'PRIVATE-TOKEN': token };
const projects = [];
let page = 1;
for (;;) {
const url = buildApiUrl(baseUrl, '/api/v4/projects', {
membership: true,
simple: true,
per_page: 100,
page,
order_by: 'last_activity_at',
});
const { json, headers: resHeaders } = await requestJson(url, headers);
for (const item of json) {
const { id, name, path_with_namespace, web_url } = item;
projects.push({ id, name, path_with_namespace, web_url });
}
const nextPage = resHeaders['x-next-page'];
if (!nextPage) break;
page = parseInt(nextPage, 10);
if (!Number.isFinite(page) || page <= 0) break;
}
return projects;
}
async function resolveAuthorQuery(baseUrl, token, username, override) {
if (override) return override;
if (!username) return null;
const url = buildApiUrl(baseUrl, '/api/v4/users', { username });
const headers = { 'PRIVATE-TOKEN': token };
const { json } = await requestJson(url, headers);
if (Array.isArray(json) && json.length > 0) {
const { name } = json[0];
return name || username;
}
return username;
}
function filterByAuthorName(commits, authorName) {
if (!authorName) return commits;
const out = [];
for (const commit of commits) {
const { author_name } = commit;
if (author_name === authorName) out.push(commit);
}
return out;
}
async function main() {
const cwd = process.cwd();
const envPath = path.join(cwd, '.env');
const env = readEnvFile(envPath);
const {
GITLAB_URL,
GITLAB_TOKEN,
GITLAB_AUTHOR_USERNAME,
} = env;
const args = parseArgs(process.argv);
const { since: sinceRaw, until: untilRaw, author: authorArg } = args;
if (!GITLAB_URL || !GITLAB_TOKEN || !sinceRaw || !untilRaw) {
process.stderr.write(
'缺少必要配置或参数。需要 GITLAB_URL, GITLAB_TOKEN, --since=YYYY-MM-DD, --until=YYYY-MM-DD\n'
);
process.exit(1);
}
const { since, until } = ensureIsoRange(sinceRaw, untilRaw);
const desiredAuthor = authorArg || 'zhouzb';
const authorQuery = await resolveAuthorQuery(GITLAB_URL, GITLAB_TOKEN, GITLAB_AUTHOR_USERNAME, desiredAuthor);
const authorInfo = { username: GITLAB_AUTHOR_USERNAME, name: desiredAuthor };
let metas = [];
if (env.GITLAB_PROJECT_IDS) {
const ids = env.GITLAB_PROJECT_IDS.split(',').map((s) => s.trim()).filter(Boolean);
for (const id of ids) {
const meta = await fetchProjectMeta(GITLAB_URL, GITLAB_TOKEN, id);
metas.push(meta);
}
} else {
metas = await fetchMembershipProjects(GITLAB_URL, GITLAB_TOKEN);
}
const grouped = { projects: [] };
for (const meta of metas) {
const { id } = meta;
const all = await fetchAllCommits(GITLAB_URL, GITLAB_TOKEN, id, since, until, authorQuery || undefined);
const filtered = filterByAuthorName(filterNonMerge(all), desiredAuthor);
if (filtered.length > 0) grouped.projects.push({ meta, commits: filtered });
}
const md = buildMarkdown({ since, until }, authorInfo, grouped);
fs.writeFileSync(path.join(cwd, 'commits.md'), md, 'utf8');
}
main().catch((e) => {
const { message } = e;
process.stderr.write(`${message}\n`);
process.exit(1);
});