阅读视图

发现新文章,点击刷新页面。

拿捏年终总结:自动提取GitLab提交记录

一、脚本功能概述

这是一个用于自动提取GitLab提交记录的Node.js脚本,专为年终总结设计。它可以:

  1. 根据指定的时间范围批量获取GitLab提交记录
  2. 过滤掉合并提交,只保留实际代码变更
  3. 按项目分组展示提交记录
  4. 生成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格式的提交汇总报告。

三、使用方法

  1. 安装依赖:无需额外依赖,使用Node.js内置模块。

  2. 配置.env文件:根据实际情况修改.env文件中的配置。

  3. 运行脚本

    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=你的提交用户名
    
  4. 查看报告:脚本会生成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);
});
❌