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
+123
View File
@@ -0,0 +1,123 @@
---
name: slack-formatting
description: Slackチャンネルに複数画像をまとめて投稿する。Slack投稿、レポート投稿、画像アップロード時に使用。curlは使わずpost-report.jsを実行すること。
allowed-tools: Bash(node:*), Read
---
# Slack Formatting ガイド
Slack への投稿方法を説明します。
## ⚠️ 重要: 必ずスクリプトを使用すること
**Slack投稿は必ず以下のスクリプトを使用してください。**
curlで直接APIを呼び出したり、独自の投稿ロジックを書いたりしないでください。
```bash
# 必ずこのスクリプトを使用
node .claude/skills/slack-formatting/scripts/post-report.js \
--message "メッセージテキスト" \
/tmp/image1.png /tmp/image2.png ...
```
## スクリプト使用方法
### 基本的な使い方
```bash
node .claude/skills/slack-formatting/scripts/post-report.js \
--message "📊 今日のコミットレポート
期間: 2026-01-06 00:00 〜 23:59 (JST)
対象: 1ブランチ / 18コミット / 5名
🐱 コミネコ で自動生成" \
/tmp/daily-summary.png \
/tmp/branch-summary-main.png \
/tmp/by-app.png \
/tmp/timeline.png \
/tmp/tips.png
```
### 環境変数
| 環境変数 | 用途 | 必須 |
|----------|------|------|
| `SLACK_BOT_TOKEN` | Bot User OAuth Token | ✅ |
| `SLACK_CHANNEL` | 投稿先チャンネルID | ※未設定時はデバッグチャンネル |
**デバッグ用チャンネル**: `YOUR_DEBUG_CHANNEL_ID`
### スクリプトが行うこと
- ✅ 複数画像を**1メッセージにまとめて**投稿
- ✅ **チャンネルに直接投稿**(スレッドではない)
- ✅ 詳細なログ出力(原因特定用)
- ✅ エラーハンドリング
### スクリプトの出力例
```
========================================
=== SLACK投稿開始 ===
========================================
[2026-01-07T07:15:00.000Z] 投稿先チャンネル: C0XXXXXXXXX
[2026-01-07T07:15:00.000Z] 画像数: 5
[2026-01-07T07:15:00.000Z] メッセージ: 📊 今日のコミットレポート...
[2026-01-07T07:15:00.000Z] [1] daily-summary.png
[2026-01-07T07:15:00.000Z] [2] branch-summary-main.png
...
========================================
=== SLACK投稿完了 ===
========================================
[2026-01-07T07:15:10.000Z] チャンネル: C0XXXXXXXXX
[2026-01-07T07:15:10.000Z] 画像数: 5
[2026-01-07T07:15:10.000Z] ✅ 投稿成功!
```
---
## 禁止事項
以下の方法は**絶対に使用しないでください**:
```bash
# ❌ 禁止: curlで直接APIを呼び出す
curl -X POST https://slack.com/api/chat.postMessage ...
# ❌ 禁止: スレッドに投稿する
curl ... -d '{"thread_ts": "xxx"}' ...
# ❌ 禁止: テキストと画像を別々に投稿
curl ... chat.postMessage # テキスト
curl ... files.upload # 画像(スレッドに)
```
---
## Block Kit 構造(参考)
テキストメッセージのフォーマットに使用できます。
### mrkdwn 記法
| 書式 | 記法 | 例 |
|------|------|-----|
| 太字 | `*text*` | *太字* |
| イタリック | `_text_` | _イタリック_ |
| 取消線 | `~text~` | ~取消線~ |
| コード | `` `code` `` | `code` |
| リンク | `<URL\|テキスト>` | <https://github.com\|GitHub> |
| メンション | `<@USER_ID>` | <@U1234> |
| チャンネル | `<#CHANNEL_ID>` | <#C1234> |
---
## トラブルシューティング
| 症状 | 原因 | 対処 |
|------|------|------|
| 画像がスレッドに投稿された | スクリプトを使っていない | `post-report.js` を使用する |
| 画像が1枚しか投稿されない | スクリプトを使っていない | `post-report.js` を使用する |
| 3回投稿された | 投稿処理が複数回呼ばれた | ログで `SLACK投稿開始` の回数を確認 |
| `not_authed` エラー | 環境変数が渡されていない | `SLACK_BOT_TOKEN` を確認 |
@@ -0,0 +1,260 @@
#!/usr/bin/env node
/**
* Slack レポート投稿スクリプト
*
* 複数の画像を1メッセージにまとめてSlackチャンネルに投稿します。
* ※スレッドではなく、チャンネルに直接投稿します
*
* Required Environment Variables:
* SLACK_BOT_TOKEN - Bot User OAuth Token (xoxb-...)
* SLACK_CHANNEL - Channel ID (C...)
*
* Usage:
* node post-report.js --message "テキスト" <image1.png> <image2.png> ...
*
* Example:
* node post-report.js --message "📊 今日のレポート" \
* /tmp/daily-summary.png /tmp/by-app.png /tmp/timeline.png
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
// 環境変数(SLACK_CHANNEL または SLACK_CHANNEL_ID をサポート)
const SLACK_TOKEN = process.env.SLACK_BOT_TOKEN;
const CHANNEL_ID = process.env.SLACK_CHANNEL || process.env.SLACK_CHANNEL_ID;
const DEBUG_CHANNEL = 'YOUR_DEBUG_CHANNEL_ID';
// ========================================
// ログ出力(原因特定用)
// ========================================
function log(message) {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] ${message}`);
}
function logSection(title) {
console.log('');
console.log('========================================');
console.log(`=== ${title} ===`);
console.log('========================================');
}
// ========================================
// HTTPS リクエスト
// ========================================
function httpsRequest(options, postData) {
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
res.setEncoding('utf8');
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
resolve({ ok: false, error: 'parse_error', raw: data });
}
});
});
req.on('error', reject);
if (postData) req.write(postData, 'utf8');
req.end();
});
}
// ========================================
// Slack API 関数
// ========================================
async function getUploadUrl(filename, fileSize) {
const params = new URLSearchParams({
filename,
length: fileSize.toString()
});
return await httpsRequest({
hostname: 'slack.com',
path: '/api/files.getUploadURLExternal',
method: 'POST',
headers: {
'Authorization': `Bearer ${SLACK_TOKEN}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
}, params.toString());
}
async function uploadFile(uploadUrl, filePath) {
const fileContent = fs.readFileSync(filePath);
const filename = path.basename(filePath);
const boundary = '----FormBoundary' + Math.random().toString(36).substring(2);
const header = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`;
const footer = `\r\n--${boundary}--\r\n`;
const body = Buffer.concat([
Buffer.from(header, 'utf-8'),
fileContent,
Buffer.from(footer, 'utf-8')
]);
const url = new URL(uploadUrl);
return new Promise((resolve, reject) => {
const req = https.request({
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': body.length
}
}, (res) => {
res.setEncoding('utf8');
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(data));
});
req.on('error', reject);
req.write(body);
req.end();
});
}
async function completeUpload(fileIds, message, channelId) {
const files = fileIds.map(id => ({ id }));
const body = JSON.stringify({
files,
channel_id: channelId,
initial_comment: message || ''
});
return await httpsRequest({
hostname: 'slack.com',
path: '/api/files.completeUploadExternal',
method: 'POST',
headers: {
'Authorization': `Bearer ${SLACK_TOKEN}`,
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body)
}
}, body);
}
// ========================================
// メイン処理
// ========================================
async function postReport(imagePaths, message) {
const channelId = CHANNEL_ID || DEBUG_CHANNEL;
logSection('SLACK投稿開始');
log(`投稿先チャンネル: ${channelId}`);
log(`画像数: ${imagePaths.length}`);
log(`メッセージ: ${message.length > 50 ? message.substring(0, 50) + '...' : message}`);
// 画像一覧を表示
imagePaths.forEach((p, i) => {
log(` [${i + 1}] ${path.basename(p)}`);
});
const fileIds = [];
// 各画像をアップロード
for (let i = 0; i < imagePaths.length; i++) {
const imagePath = imagePaths[i];
const filename = path.basename(imagePath);
const fileSize = fs.statSync(imagePath).size;
log(`アップロード中: ${filename} (${Math.round(fileSize / 1024)}KB)`);
// Step 1: Get upload URL
const urlResponse = await getUploadUrl(filename, fileSize);
if (!urlResponse.ok) {
log(` エラー: ${urlResponse.error}`);
continue;
}
// Step 2: Upload file
await uploadFile(urlResponse.upload_url, imagePath);
log(` 完了: ${urlResponse.file_id}`);
fileIds.push(urlResponse.file_id);
}
if (fileIds.length === 0) {
logSection('SLACK投稿エラー');
log('アップロードされたファイルがありません');
return { ok: false, error: 'no_files_uploaded' };
}
// Step 3: Complete upload (posts all files at once to channel, NOT thread)
log('');
log('チャンネルに投稿中...');
const result = await completeUpload(fileIds, message, channelId);
if (result.ok) {
logSection('SLACK投稿完了');
log(`チャンネル: ${channelId}`);
log(`画像数: ${fileIds.length}`);
log('✅ 投稿成功!');
} else {
logSection('SLACK投稿エラー');
log(`エラー: ${result.error}`);
if (result.raw) log(`詳細: ${result.raw}`);
}
return result;
}
async function main() {
// 環境変数チェック
if (!SLACK_TOKEN) {
console.error('エラー: SLACK_BOT_TOKEN 環境変数が必要です');
process.exit(1);
}
if (!CHANNEL_ID) {
log(`警告: SLACK_CHANNEL が未設定のため、デバッグチャンネル (${DEBUG_CHANNEL}) に投稿します`);
}
// 引数パース
const args = process.argv.slice(2);
let message = '';
const imagePaths = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === '--message' || args[i] === '-m') {
message = args[++i] || '';
} else if (args[i].startsWith('-')) {
console.error(`不明なオプション: ${args[i]}`);
process.exit(1);
} else {
imagePaths.push(args[i]);
}
}
// 使い方表示
if (imagePaths.length === 0) {
console.error('使い方: node post-report.js --message "テキスト" <image1.png> ...');
console.error('');
console.error('必須環境変数:');
console.error(' SLACK_BOT_TOKEN - Bot OAuth Token');
console.error(' SLACK_CHANNEL - チャンネルID');
process.exit(1);
}
// ファイル存在確認
const missingFiles = imagePaths.filter(p => !fs.existsSync(p));
if (missingFiles.length > 0) {
console.error('ファイルが見つかりません:');
missingFiles.forEach(f => console.error(` - ${f}`));
process.exit(1);
}
// 投稿実行
const result = await postReport(imagePaths, message);
process.exit(result.ok ? 0 : 1);
}
main();