feat: コミットレポートツール(コミネコ)初期コミット

Made-with: Cursor
This commit is contained in:
hiroki ito
2026-04-03 19:31:32 +09:00
commit c872debe4b
29 changed files with 4344 additions and 0 deletions
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env node
/**
* コミットをパスでフィルタリングするスクリプト
*
* get-all-branch-commits.js の出力を受け取り、指定パスに関連するコミットのみを抽出。
* 各コミットの変更ファイルを GitHub API で取得し、パスマッチングを行う。
*
* 使い方:
* # パイプライン
* node get-all-branch-commits.js owner repo | node filter-commits-by-path.js --owner owner --repo repo --paths "app/,web/"
*
* # ファイル入力
* node filter-commits-by-path.js --input /tmp/commits.json --owner owner --repo repo --paths "app/,web/"
*
* 引数:
* --input <file> 入力ファイル(省略時stdin)
* --owner <name> リポジトリオーナー(必須)
* --repo <name> リポジトリ名(必須)
* --paths <list> カンマ区切りパス(必須)例: "app/,web/"
* --concurrency <n> 並列数(デフォルト: 5)
*/
const { execSync } = require('child_process');
const fs = require('fs');
/**
* コマンドライン引数を解析する
* @returns {{ input: string|null, owner: string, repo: string, paths: string[], concurrency: number }}
*/
function parseArgs() {
const args = process.argv.slice(2);
const parsed = { input: null, owner: null, repo: null, paths: null, concurrency: 5 };
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--input':
parsed.input = args[++i];
break;
case '--owner':
parsed.owner = args[++i];
break;
case '--repo':
parsed.repo = args[++i];
break;
case '--paths':
parsed.paths = args[++i];
break;
case '--concurrency':
parsed.concurrency = parseInt(args[++i], 10);
break;
}
}
if (!parsed.owner || !parsed.repo || !parsed.paths) {
console.error('Usage: node filter-commits-by-path.js --owner <owner> --repo <repo> --paths <paths>');
console.error('');
console.error('Required:');
console.error(' --owner <name> Repository owner');
console.error(' --repo <name> Repository name');
console.error(' --paths <list> Comma-separated paths (e.g. "app/,web/,supabase/")');
console.error('');
console.error('Optional:');
console.error(' --input <file> Input file (default: stdin)');
console.error(' --concurrency <n> Parallel requests (default: 5)');
process.exit(1);
}
return {
input: parsed.input,
owner: parsed.owner,
repo: parsed.repo,
paths: parsed.paths.split(',').map(p => p.trim()).filter(Boolean),
concurrency: parsed.concurrency
};
}
/**
* stdinまたはファイルからJSONを読み込む
* @param {string|null} inputFile
* @returns {object}
*/
function readInput(inputFile) {
let raw;
if (inputFile) {
raw = fs.readFileSync(inputFile, 'utf-8');
} else {
raw = fs.readFileSync(0, 'utf-8'); // stdin
}
try {
return JSON.parse(raw);
} catch (e) {
console.error('JSON解析エラー:', e.message);
process.exit(1);
}
}
/**
* コミットの変更ファイル一覧を GitHub API で取得する
* @param {string} owner
* @param {string} repo
* @param {string} sha
* @returns {{ filename: string, status: string, additions: number, deletions: number }[] | null}
*/
function getCommitFiles(owner, repo, sha) {
const cmd = `gh api "repos/${owner}/${repo}/commits/${sha}" --jq '[.files[] | {filename, status, additions, deletions}]'`;
try {
const result = execSync(cmd, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
return JSON.parse(result);
} catch (error) {
console.error(`[${sha.substring(0, 7)}] ファイル取得エラー: ${error.message.split('\n')[0]}`);
return null;
}
}
/**
* セマフォパターンで並列数を制限しながら処理を実行する
* @param {Array} items
* @param {function} processor
* @param {number} concurrency
* @returns {Promise<Array>}
*/
async function processWithConcurrency(items, processor, concurrency) {
const results = new Array(items.length);
let index = 0;
let completed = 0;
async function worker() {
while (index < items.length) {
const i = index++;
results[i] = await processor(items[i], i);
completed++;
if (completed % 10 === 0) {
console.error(` 進捗: ${completed}/${items.length}`);
}
}
}
const workers = [];
for (let i = 0; i < Math.min(concurrency, items.length); i++) {
workers.push(worker());
}
await Promise.all(workers);
return results;
}
/**
* メイン処理
*/
async function main() {
const { input, owner, repo, paths, concurrency } = parseArgs();
console.error('=== パスフィルタリング ===');
console.error(`リポジトリ: ${owner}/${repo}`);
console.error(`対象パス: ${paths.join(', ')}`);
console.error(`並列数: ${concurrency}`);
console.error('=========================');
// 入力読み込み
const data = readInput(input);
// 全ブランチから全コミットを収集(重複排除)
/** @type {{ sha: string, message: string, date: string, author: object, html_url: string, branch: string }[]} */
const allCommits = [];
const seenShas = new Set();
for (const [branchName, branchData] of Object.entries(data.branches)) {
for (const commit of branchData.commits) {
if (!seenShas.has(commit.sha)) {
seenShas.add(commit.sha);
allCommits.push({ ...commit, branch: branchName });
}
}
}
const originalCount = allCommits.length;
console.error(`全コミット数: ${originalCount}(重複排除済み)`);
if (originalCount === 0) {
// コミットがない場合はそのまま出力
const result = {
metadata: {
...data.metadata,
filter_paths: paths,
original_commits: 0,
filtered_commits: 0
},
branches: {}
};
console.log(JSON.stringify(result, null, 2));
return;
}
// 並列でファイル取得 & パスマッチング
console.error(`ファイル情報を取得中(並列数: ${concurrency})...`);
const matchResults = await processWithConcurrency(
allCommits,
(commit) => {
const files = getCommitFiles(owner, repo, commit.sha);
if (!files) return { commit, matchedFiles: [] };
const matchedFiles = files.filter(f =>
paths.some(p => f.filename.startsWith(p))
);
return { commit, matchedFiles };
},
concurrency
);
// マッチしたコミットのみでブランチ構造を再構築
const filteredBranches = {};
let filteredCount = 0;
for (const { commit, matchedFiles } of matchResults) {
if (matchedFiles.length === 0) continue;
filteredCount++;
const branchName = commit.branch;
if (!filteredBranches[branchName]) {
const originalBranch = data.branches[branchName];
filteredBranches[branchName] = {
commits: [],
is_default: originalBranch.is_default
};
}
// ブランチ情報はコミットから除外(出力形式を元の構造と合わせる)
const { branch, ...commitData } = commit;
filteredBranches[branchName].commits.push({
...commitData,
matched_files: matchedFiles
});
}
// 結果出力
const result = {
metadata: {
...data.metadata,
filter_paths: paths,
original_commits: originalCount,
filtered_commits: filteredCount
},
branches: filteredBranches
};
// 更新されたメタデータ
result.metadata.active_branches = Object.keys(filteredBranches).length;
result.metadata.total_commits = filteredCount;
console.error('');
console.error(`=== フィルタ結果 ===`);
console.error(`元のコミット: ${originalCount}`);
console.error(`フィルタ後: ${filteredCount}`);
console.error(`アクティブブランチ: ${Object.keys(filteredBranches).length}`);
console.error('====================');
console.log(JSON.stringify(result, null, 2));
}
main();
@@ -0,0 +1,315 @@
#!/usr/bin/env node
/**
* 全ブランチの前日(JST)コミットを一括取得するスクリプト
*
* 最適化: GraphQLで最近アクティブなブランチのみを抽出してからコミット取得
*
* 使い方:
* node get-all-branch-commits.js <owner> <repo> [--output <file>]
*
* 環境変数:
* GH_TOKEN: GitHub API認証トークン
* TARGET_DATE: 対象日(省略時は前日JST)形式: YYYY-MM-DD
* ACTIVE_DAYS: アクティブとみなす日数(省略時は7日)
*
* 出力:
* 全ブランチのコミットをJSON形式で出力
* --output 指定時はファイルに書き込み(シェルリダイレクトより確実)
*/
const { execSync } = require('child_process');
const fs = require('fs');
const { normalizeCommits } = require('./lib/normalize');
const { getYesterdayJST, getTargetDateJST } = require('./lib/date-utils');
// 除外するブランチのプレフィックス
const EXCLUDED_PREFIXES = ['dependabot/', 'renovate/'];
// GraphQLでブランチ一覧と最終コミット日を取得(ページネーション対応)
function getAllBranchesWithDates(owner, repo) {
const allBranches = [];
let hasNextPage = true;
let cursor = null;
while (hasNextPage) {
const afterClause = cursor ? `, after: "${cursor}"` : '';
const query = `
{
repository(owner: "${owner}", name: "${repo}") {
defaultBranchRef {
name
}
refs(refPrefix: "refs/heads/", first: 100${afterClause}) {
nodes {
name
target {
... on Commit {
committedDate
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}`;
try {
const result = execSync(
`gh api graphql -f query='${query.replace(/'/g, "'\\''")}'`,
{ encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 }
);
const data = JSON.parse(result);
const refs = data.data.repository.refs;
const defaultBranch = data.data.repository.defaultBranchRef?.name || 'main';
for (const node of refs.nodes) {
if (!EXCLUDED_PREFIXES.some(prefix => node.name.startsWith(prefix))) {
allBranches.push({
name: node.name,
lastCommitDate: node.target?.committedDate || null,
isDefault: node.name === defaultBranch
});
}
}
hasNextPage = refs.pageInfo.hasNextPage;
cursor = refs.pageInfo.endCursor;
} catch (error) {
console.error('GraphQLエラー:', error.message);
process.exit(1);
}
}
return allBranches;
}
// 特定ブランチのコミットを取得(リトライ機能付き)
function getBranchCommits(owner, repo, branch, startUTC, endUTC, maxRetries = 3) {
const cmd = `gh api "repos/${owner}/${repo}/commits?sha=${encodeURIComponent(branch)}&since=${startUTC}&until=${endUTC}" --paginate`;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const rawResult = execSync(cmd, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
const rawCommits = JSON.parse(rawResult);
return { success: true, commits: normalizeCommits(rawCommits) };
} catch (error) {
const errorMsg = error.message.split('\n')[0];
if (attempt < maxRetries) {
console.error(`[${branch}] リトライ ${attempt}/${maxRetries}: ${errorMsg}`);
const waitMs = Math.pow(2, attempt - 1) * 1000;
execSync(`sleep ${waitMs / 1000}`);
} else {
console.error(`[${branch}] ⚠️ 取得失敗(${maxRetries}回リトライ後): ${errorMsg}`);
return { success: false, commits: [], error: errorMsg };
}
}
}
return { success: false, commits: [], error: 'Unknown error' };
}
// アクティブブランチをフィルタリング
function filterActiveBranches(allBranches, activeDays, targetDateStr) {
const targetDate = new Date(targetDateStr);
const cutoffDate = new Date(targetDate);
cutoffDate.setDate(cutoffDate.getDate() - activeDays);
const cutoffStr = cutoffDate.toISOString();
return allBranches.filter(branch => {
// デフォルトブランチは常に含める
if (branch.isDefault) return true;
// 最終コミット日がない場合はスキップ
if (!branch.lastCommitDate) return false;
// 最終コミット日がカットオフ以降なら含める
return branch.lastCommitDate >= cutoffStr;
});
}
// 引数パース
function parseArgs() {
const args = process.argv.slice(2);
let owner = null;
let repo = null;
let outputFile = null;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--output' || args[i] === '-o') {
outputFile = args[++i];
} else if (!owner) {
owner = args[i];
} else if (!repo) {
repo = args[i];
}
}
return { owner, repo, outputFile };
}
// メイン処理
function main() {
const { owner, repo, outputFile } = parseArgs();
if (!owner || !repo) {
console.error('Usage: node get-all-branch-commits.js <owner> <repo> [--output <file>]');
console.error('');
console.error('Options:');
console.error(' --output, -o <file> Write JSON to file instead of stdout (recommended)');
console.error('');
console.error('Environment variables:');
console.error(' GH_TOKEN: GitHub API token (required)');
console.error(' TARGET_DATE: Target date in YYYY-MM-DD format (optional, defaults to yesterday JST)');
console.error(' ACTIVE_DAYS: Days to consider a branch active (optional, defaults to 7)');
process.exit(1);
}
// 日付計算
const targetDate = process.env.TARGET_DATE;
const activeDays = parseInt(process.env.ACTIVE_DAYS || '7', 10);
const { date, startUTC, endUTC } = targetDate
? getTargetDateJST(targetDate)
: getYesterdayJST();
console.error('=== 全ブランチコミット取得(最適化版) ===');
console.error(`対象日(JST): ${date}`);
console.error(`開始(UTC): ${startUTC}`);
console.error(`終了(UTC): ${endUTC}`);
console.error(`アクティブ判定: 過去${activeDays}日以内`);
console.error('==========================================');
// ステップ1: GraphQLで全ブランチと最終コミット日を取得
console.error('');
console.error('[ステップ1] GraphQLでブランチ一覧を取得中...');
const allBranches = getAllBranchesWithDates(owner, repo);
const defaultBranch = allBranches.find(b => b.isDefault)?.name || 'main';
console.error(`総ブランチ数: ${allBranches.length}`);
console.error(`デフォルトブランチ: ${defaultBranch}`);
// ステップ2: アクティブブランチをフィルタリング
console.error('');
console.error('[ステップ2] アクティブブランチをフィルタリング...');
const activeBranches = filterActiveBranches(allBranches, activeDays, date);
console.error(`アクティブブランチ: ${activeBranches.length}件(${allBranches.length}件中)`);
activeBranches.forEach(b => {
const marker = b.isDefault ? '(default)' : '';
console.error(` - ${b.name} ${marker}`);
});
// ステップ3: アクティブブランチのコミットを取得
console.error('');
console.error('[ステップ3] コミットを取得中...');
const branches = {};
let activeBranchCount = 0;
let totalCommits = 0;
let failedBranches = [];
for (let i = 0; i < activeBranches.length; i++) {
const branch = activeBranches[i];
console.error(`[${i + 1}/${activeBranches.length}] [${branch.name}] コミット取得中...`);
const result = getBranchCommits(owner, repo, branch.name, startUTC, endUTC);
if (!result.success) {
failedBranches.push({ name: branch.name, error: result.error });
}
if (result.commits.length > 0) {
branches[branch.name] = {
commits: result.commits,
is_default: branch.isDefault
};
activeBranchCount++;
totalCommits += result.commits.length;
console.error(`[${branch.name}] ${result.commits.length}件のコミットを検出`);
}
}
// 失敗したブランチがあれば、10秒待ってから再試行
if (failedBranches.length > 0) {
console.error('');
console.error(`=== 失敗ブランチの再取得 (${failedBranches.length}件) ===`);
console.error('10秒待機してから再試行...');
execSync('sleep 10');
const stillFailed = [];
for (let i = 0; i < failedBranches.length; i++) {
const branchName = failedBranches[i].name;
const branchInfo = activeBranches.find(b => b.name === branchName);
console.error(`[再試行 ${i + 1}/${failedBranches.length}] [${branchName}] コミット取得中...`);
const result = getBranchCommits(owner, repo, branchName, startUTC, endUTC);
if (!result.success) {
stillFailed.push({ name: branchName, error: result.error });
} else if (result.commits.length > 0) {
branches[branchName] = {
commits: result.commits,
is_default: branchInfo?.isDefault || false
};
activeBranchCount++;
totalCommits += result.commits.length;
console.error(`[${branchName}] ✅ 再取得成功: ${result.commits.length}件のコミット`);
} else {
console.error(`[${branchName}] ✅ 再取得成功: コミットなし`);
}
}
failedBranches = stillFailed;
console.error(`再取得完了: 残り失敗 ${failedBranches.length}件`);
}
// 結果を出力
const result = {
metadata: {
target_date: date,
start_utc: startUTC,
end_utc: endUTC,
total_branches: allBranches.length,
checked_branches: activeBranches.length,
active_branches: activeBranchCount,
total_commits: totalCommits,
default_branch: defaultBranch,
active_days_filter: activeDays,
failed_branches: failedBranches.length,
has_errors: failedBranches.length > 0
},
branches: branches
};
if (failedBranches.length > 0) {
result.errors = failedBranches;
}
console.error('');
console.error(`=== 結果 ===`);
console.error(`総ブランチ: ${allBranches.length}`);
console.error(`チェック対象: ${activeBranches.length}(最適化で${Math.round((1 - activeBranches.length / allBranches.length) * 100)}%削減)`);
console.error(`コミットあり: ${activeBranchCount}`);
console.error(`総コミット数: ${totalCommits}`);
if (failedBranches.length > 0) {
console.error(`❌ 取得失敗ブランチ: ${failedBranches.length}件`);
failedBranches.forEach(fb => {
console.error(` - ${fb.name}: ${fb.error}`);
});
}
console.error('============');
// JSON出力
const jsonOutput = JSON.stringify(result, null, 2);
if (outputFile) {
// ファイルに書き込み(stdout/stderrの混在を防ぐ)
fs.writeFileSync(outputFile, jsonOutput, 'utf-8');
console.error(`✅ 出力ファイル: ${outputFile}`);
} else {
// 標準出力(後方互換性)
console.log(jsonOutput);
}
// 失敗があれば exit 1 で終了
if (failedBranches.length > 0) {
console.error('');
console.error('❌ 一部ブランチの取得に失敗しました。処理を中断します。');
process.exit(1);
}
}
main();
@@ -0,0 +1,63 @@
#!/usr/bin/env node
/**
* 前日(JST)のコミットを確実に取得するスクリプト
*
* 使い方:
* node get-commits.js <owner> <repo> [branch]
*
* 環境変数:
* GH_TOKEN: GitHub API認証トークン
* TARGET_DATE: 対象日(省略時は前日JST)形式: YYYY-MM-DD
*
* 出力:
* 正規化されたJSONを標準出力に出力(jq処理不要)
* - sha, message, date, author.login, author.avatar_url, html_url を保証
*/
const { execSync } = require('child_process');
const { normalizeCommits } = require('./lib/normalize');
const { getYesterdayJST, getTargetDateJST } = require('./lib/date-utils');
// メイン処理
function main() {
const [owner, repo, branch = 'main'] = process.argv.slice(2);
if (!owner || !repo) {
console.error('Usage: node get-commits.js <owner> <repo> [branch]');
console.error('');
console.error('Environment variables:');
console.error(' GH_TOKEN: GitHub API token (required)');
console.error(' TARGET_DATE: Target date in YYYY-MM-DD format (optional, defaults to yesterday JST)');
process.exit(1);
}
// 日付計算
const targetDate = process.env.TARGET_DATE;
const { date, startUTC, endUTC } = targetDate
? getTargetDateJST(targetDate)
: getYesterdayJST();
// ログ出力(デバッグ用、標準エラーへ)
console.error('=== 検索期間 ===');
console.error(`対象日(JST): ${date}`);
console.error(`開始(UTC): ${startUTC}`);
console.error(`終了(UTC): ${endUTC}`);
console.error(`ブランチ: ${branch}`);
console.error('================');
// gh API呼び出し
const cmd = `gh api "repos/${owner}/${repo}/commits?sha=${branch}&since=${startUTC}&until=${endUTC}" --paginate`;
try {
const rawResult = execSync(cmd, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
const rawCommits = JSON.parse(rawResult);
const normalized = normalizeCommits(rawCommits);
// 正規化されたJSON出力(標準出力)
console.log(JSON.stringify(normalized, null, 2));
} catch (error) {
console.error('API呼び出しエラー:', error.message);
process.exit(1);
}
}
main();
@@ -0,0 +1,45 @@
function getYesterdayJST() {
const now = new Date();
const jstOffset = 9 * 60 * 60 * 1000;
const jstNow = new Date(now.getTime() + jstOffset);
const yesterday = new Date(jstNow);
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
const year = yesterday.getUTCFullYear();
const month = yesterday.getUTCMonth();
const day = yesterday.getUTCDate();
const start = new Date(Date.UTC(year, month, day, 0, 0, 0));
start.setTime(start.getTime() - jstOffset);
const end = new Date(Date.UTC(year, month, day, 23, 59, 59));
end.setTime(end.getTime() - jstOffset);
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
return {
date: dateStr,
startUTC: start.toISOString().replace('.000Z', 'Z'),
endUTC: end.toISOString().replace('.000Z', 'Z')
};
}
function getTargetDateJST(dateStr) {
const [year, month, day] = dateStr.split('-').map(Number);
const jstOffset = 9 * 60 * 60 * 1000;
const start = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
start.setTime(start.getTime() - jstOffset);
const end = new Date(Date.UTC(year, month - 1, day, 23, 59, 59));
end.setTime(end.getTime() - jstOffset);
return {
date: dateStr,
startUTC: start.toISOString().replace('.000Z', 'Z'),
endUTC: end.toISOString().replace('.000Z', 'Z')
};
}
module.exports = { getYesterdayJST, getTargetDateJST };
@@ -0,0 +1,30 @@
const crypto = require('crypto');
function normalizeCommits(rawCommits) {
return rawCommits.map(commit => {
let author;
if (commit.author?.login && commit.author?.avatar_url) {
author = { login: commit.author.login, avatar_url: commit.author.avatar_url };
} else {
const email = commit.commit?.author?.email || '';
const name = commit.commit?.author?.name || 'Unknown';
const hash = email
? crypto.createHash('md5').update(email.toLowerCase().trim()).digest('hex')
: '00000000000000000000000000000000';
author = {
login: name,
avatar_url: `https://www.gravatar.com/avatar/${hash}?d=identicon&s=80`
};
console.error(`[Avatar] フォールバック: ${name} (${commit.sha.substring(0, 7)})`);
}
return {
sha: commit.sha,
message: commit.commit.message,
date: commit.commit.author.date,
author,
html_url: commit.html_url
};
});
}
module.exports = { normalizeCommits };