#!/usr/bin/env node /** * Linear 프로젝트 대시보드 생성기 * Linear 데이터를 기반으로 자동화된 프로젝트 리포팅 대시보드 생성 */ const fs = require("fs"); const path = require("path"); // Simple command line argument parsing const args = process.argv.slice(2); const options = { apiKey: null, period: "7d", // 7d, 30d, 90d output: "dashboard", format: "html", // html, json, markdown verbose: args.includes("--verbose") || args.includes("-v"), help: args.includes("--help") || args.includes("-h"), }; // Extract arguments args.forEach((arg) => { if (arg.startsWith("--api-key=")) { options.apiKey = arg.split("=")[1]; } else if (arg.startsWith("--period=")) { options.period = arg.split("=")[1]; } else if (arg.startsWith("--output=")) { options.output = arg.split("=")[1]; } else if (arg.startsWith("--format=")) { options.format = arg.split("=")[1]; } }); /** * Linear API 클라이언트 */ class LinearDashboardClient { constructor(apiKey) { this.apiKey = apiKey; this.baseUrl = "https://api.linear.app/graphql"; } async query(query, variables = {}) { try { const response = await fetch(this.baseUrl, { method: "POST", headers: { "Content-Type": "application/json", Authorization: this.apiKey, }, body: JSON.stringify({ query, variables }), }); const data = await response.json(); if (data.errors) { throw new Error(`Linear API Error: ${JSON.stringify(data.errors)}`); } return data.data; } catch (error) { throw new Error(`Linear API call failed: ${error.message}`); } } async getOrganizationData() { const query = ` query GetOrganizationData { organization { id name urlKey createdAt userCount teams { nodes { id name key description issueCount members { nodes { id name email isMe } } } } } } `; return await this.query(query); } async getProjectStats(period = "30d") { const sinceDate = this.getPeriodDate(period); const query = ` query GetProjectStats($since: DateTimeOrDuration!) { issues( filter: { updatedAt: { gte: $since } } first: 250 ) { nodes { id identifier title description state { id name type } priority estimate labels { nodes { id name color } } assignee { id name email } team { id name key } project { id name description } createdAt updatedAt completedAt cycle { id name number } } } projects(first: 50) { nodes { id name description state progress startDate targetDate completedAt lead { id name } teams { nodes { id name } } issues { nodes { id state { name type } } } } } } `; return await this.query(query, { since: sinceDate.toISOString() }); } async getCycleData() { const query = ` query GetCycleData { cycles( filter: { isActive: { eq: true } } first: 10 ) { nodes { id name number description startsAt endsAt progress team { id name } issues { nodes { id identifier title state { name type } estimate assignee { name } } } } } } `; return await this.query(query); } getPeriodDate(period) { const now = new Date(); const days = parseInt(period.replace("d", "")); const sinceDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); return sinceDate; } } /** * 대시보드 데이터 분석기 */ class DashboardAnalyzer { constructor(data) { this.data = data; } analyzeIssueMetrics() { const issues = this.data.projectStats.issues.nodes; const metrics = { total: issues.length, byState: {}, byPriority: {}, byTeam: {}, byAssignee: {}, completed: 0, inProgress: 0, backlog: 0, avgCompletionTime: 0, estimateAccuracy: 0, }; let completionTimes = []; let estimateData = []; issues.forEach((issue) => { // State analysis const stateName = issue.state.name; metrics.byState[stateName] = (metrics.byState[stateName] || 0) + 1; if (issue.state.type === "completed") { metrics.completed++; // Completion time calculation if (issue.completedAt && issue.createdAt) { const completion = new Date(issue.completedAt); const creation = new Date(issue.createdAt); const days = (completion - creation) / (1000 * 60 * 60 * 24); completionTimes.push(days); } } else if (["started", "unstarted"].includes(issue.state.type)) { metrics.inProgress++; } else { metrics.backlog++; } // Priority analysis const priority = issue.priority || 0; metrics.byPriority[priority] = (metrics.byPriority[priority] || 0) + 1; // Team analysis const teamName = issue.team?.name || "No Team"; metrics.byTeam[teamName] = (metrics.byTeam[teamName] || 0) + 1; // Assignee analysis const assigneeName = issue.assignee?.name || "Unassigned"; metrics.byAssignee[assigneeName] = (metrics.byAssignee[assigneeName] || 0) + 1; // Estimate accuracy if (issue.estimate && issue.completedAt) { estimateData.push({ estimate: issue.estimate, actual: completionTimes[completionTimes.length - 1] || 0, }); } }); // Calculate averages if (completionTimes.length > 0) { metrics.avgCompletionTime = completionTimes.reduce((a, b) => a + b, 0) / completionTimes.length; } if (estimateData.length > 0) { const accuracyScores = estimateData.map((item) => { const accuracy = 1 - Math.abs(item.actual - item.estimate) / Math.max(item.actual, item.estimate, 1); return Math.max(0, accuracy); }); metrics.estimateAccuracy = accuracyScores.reduce((a, b) => a + b, 0) / accuracyScores.length; } return metrics; } analyzeProjectMetrics() { const projects = this.data.projectStats.projects.nodes; const metrics = { total: projects.length, active: 0, completed: 0, onTrack: 0, delayed: 0, byLead: {}, avgProgress: 0, healthScore: 0, }; let progressValues = []; let healthScores = []; projects.forEach((project) => { // State analysis if (project.state === "completed") { metrics.completed++; } else { metrics.active++; } // Progress analysis if (project.progress !== null && project.progress !== undefined) { progressValues.push(project.progress); // Health score calculation let healthScore = project.progress; // Check if on track with timeline if (project.targetDate) { const now = new Date(); const target = new Date(project.targetDate); const start = project.startDate ? new Date(project.startDate) : now; const totalDuration = target - start; const elapsed = now - start; const expectedProgress = Math.min( 1, Math.max(0, elapsed / totalDuration) ); if (project.progress >= expectedProgress * 0.9) { metrics.onTrack++; healthScore *= 1.1; // Bonus for being on track } else { metrics.delayed++; healthScore *= 0.8; // Penalty for being delayed } } healthScores.push(Math.min(1, healthScore)); } // Lead analysis const leadName = project.lead?.name || "No Lead"; metrics.byLead[leadName] = (metrics.byLead[leadName] || 0) + 1; }); // Calculate averages if (progressValues.length > 0) { metrics.avgProgress = progressValues.reduce((a, b) => a + b, 0) / progressValues.length; } if (healthScores.length > 0) { metrics.healthScore = healthScores.reduce((a, b) => a + b, 0) / healthScores.length; } return metrics; } analyzeCycleMetrics() { const cycles = this.data.cycleData.cycles.nodes; const metrics = { total: cycles.length, avgProgress: 0, totalIssues: 0, completedIssues: 0, velocity: 0, burndown: [], }; let progressValues = []; let velocityData = []; cycles.forEach((cycle) => { if (cycle.progress !== null && cycle.progress !== undefined) { progressValues.push(cycle.progress); } const issues = cycle.issues.nodes; metrics.totalIssues += issues.length; let completedInCycle = 0; let totalEstimate = 0; let completedEstimate = 0; issues.forEach((issue) => { if (issue.state.type === "completed") { completedInCycle++; metrics.completedIssues++; } if (issue.estimate) { totalEstimate += issue.estimate; if (issue.state.type === "completed") { completedEstimate += issue.estimate; } } }); // Velocity calculation (story points per cycle) if (totalEstimate > 0) { velocityData.push(completedEstimate); } // Burndown data if (cycle.startsAt && cycle.endsAt) { const start = new Date(cycle.startsAt); const end = new Date(cycle.endsAt); const now = new Date(); const progress = Math.min( 1, Math.max(0, (now - start) / (end - start)) ); const remainingWork = totalEstimate - completedEstimate; metrics.burndown.push({ cycleName: cycle.name, progress: progress, remainingWork: remainingWork, totalWork: totalEstimate, completedWork: completedEstimate, }); } }); // Calculate averages if (progressValues.length > 0) { metrics.avgProgress = progressValues.reduce((a, b) => a + b, 0) / progressValues.length; } if (velocityData.length > 0) { metrics.velocity = velocityData.reduce((a, b) => a + b, 0) / velocityData.length; } return metrics; } analyzeTeamMetrics() { const organization = this.data.organizationData.organization; const issues = this.data.projectStats.issues.nodes; const metrics = { totalTeams: organization.teams.nodes.length, totalMembers: organization.userCount, teamsData: {}, }; // Initialize team data organization.teams.nodes.forEach((team) => { metrics.teamsData[team.name] = { id: team.id, key: team.key, memberCount: team.members.nodes.length, totalIssues: 0, completedIssues: 0, inProgressIssues: 0, velocity: 0, avgCompletionTime: 0, members: team.members.nodes.map((member) => ({ name: member.name, email: member.email, isMe: member.isMe, assignedIssues: 0, completedIssues: 0, })), }; }); // Analyze issues by team issues.forEach((issue) => { const teamName = issue.team?.name; if (teamName && metrics.teamsData[teamName]) { const teamData = metrics.teamsData[teamName]; teamData.totalIssues++; if (issue.state.type === "completed") { teamData.completedIssues++; } else if (["started", "unstarted"].includes(issue.state.type)) { teamData.inProgressIssues++; } // Member-specific metrics const assigneeName = issue.assignee?.name; if (assigneeName) { const member = teamData.members.find((m) => m.name === assigneeName); if (member) { member.assignedIssues++; if (issue.state.type === "completed") { member.completedIssues++; } } } } }); // Calculate team velocities and completion rates Object.values(metrics.teamsData).forEach((team) => { if (team.totalIssues > 0) { team.velocity = team.completedIssues / team.totalIssues; } }); return metrics; } generateSummary() { const issueMetrics = this.analyzeIssueMetrics(); const projectMetrics = this.analyzeProjectMetrics(); const cycleMetrics = this.analyzeCycleMetrics(); const teamMetrics = this.analyzeTeamMetrics(); return { generated: new Date().toISOString(), period: options.period, organization: this.data.organizationData.organization.name, summary: { totalIssues: issueMetrics.total, completedIssues: issueMetrics.completed, completionRate: issueMetrics.total > 0 ? issueMetrics.completed / issueMetrics.total : 0, avgCompletionTime: issueMetrics.avgCompletionTime, totalProjects: projectMetrics.total, projectHealthScore: projectMetrics.healthScore, totalTeams: teamMetrics.totalTeams, totalMembers: teamMetrics.totalMembers, velocity: cycleMetrics.velocity, }, issues: issueMetrics, projects: projectMetrics, cycles: cycleMetrics, teams: teamMetrics, }; } } /** * HTML 대시보드 생성기 */ class HtmlDashboardGenerator { constructor(summary) { this.summary = summary; } generate() { return ` Linear Project Dashboard - ${this.summary.organization}

📊 Linear Project Dashboard

Organization: ${this.summary.organization} | Period: ${this.summary.period} | Generated: ${new Date(this.summary.generated).toLocaleString("ko-KR")}
${this.generateSummaryCard()} ${this.generateIssueMetricsCard()} ${this.generateProjectMetricsCard()} ${this.generateCycleMetricsCard()}

📈 Issue Status Distribution

👥 Team Performance

${this.generateTeamCards()}
`; } generateSummaryCard() { const s = this.summary.summary; return `

📋 Project Summary

Total Issues ${s.totalIssues}
Completed Issues ${s.completedIssues}
Completion Rate 0.6 ? "status-warning" : "status-danger"}">${(s.completionRate * 100).toFixed(1)}%
Avg Completion Time ${s.avgCompletionTime.toFixed(1)} days
Team Velocity ${s.velocity.toFixed(1)} pts/cycle
`; } generateIssueMetricsCard() { const issues = this.summary.issues; return `

🎯 Issue Metrics

Total Issues ${issues.total}
Completed ${issues.completed}
In Progress ${issues.inProgress}
Backlog ${issues.backlog}
Estimate Accuracy ${(issues.estimateAccuracy * 100).toFixed(1)}%
`; } generateProjectMetricsCard() { const projects = this.summary.projects; return `

🚀 Project Metrics

Total Projects ${projects.total}
Active Projects ${projects.active}
Completed Projects ${projects.completed}
On Track ${projects.onTrack}
Delayed ${projects.delayed}
Health Score 0.6 ? "status-warning" : "status-danger"}">${(projects.healthScore * 100).toFixed(1)}%
`; } generateCycleMetricsCard() { const cycles = this.summary.cycles; return `

🔄 Cycle Metrics

Active Cycles ${cycles.total}
Total Issues ${cycles.totalIssues}
Completed Issues ${cycles.completedIssues}
Average Progress ${(cycles.avgProgress * 100).toFixed(1)}%
Velocity ${cycles.velocity.toFixed(1)} pts/cycle
`; } generateTeamCards() { return Object.entries(this.summary.teams.teamsData) .map( ([teamName, team]) => `

${teamName} (${team.key})

${team.memberCount} members
Issues ${team.totalIssues}
Completed ${team.completedIssues}
Velocity ${(team.velocity * 100).toFixed(1)}%
` ) .join(""); } generateChartScripts() { const issueStates = this.summary.issues.byState; const stateLabels = Object.keys(issueStates); const stateData = Object.values(issueStates); return ` // Issue Status Chart const ctx = document.getElementById('issueStatusChart').getContext('2d'); new Chart(ctx, { type: 'doughnut', data: { labels: ${JSON.stringify(stateLabels)}, datasets: [{ data: ${JSON.stringify(stateData)}, backgroundColor: [ '#10b981', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4' ] }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom' } } } }); `; } } /** * 사용법 출력 */ function printUsage() { console.log(` 📊 Linear 프로젝트 대시보드 생성기 사용법: node scripts/linear-dashboard-generator.cjs [옵션] 옵션: --api-key=KEY Linear API 키 지정 --period=PERIOD 데이터 수집 기간 (7d, 30d, 90d) [기본: 7d] --output=NAME 출력 파일명 [기본: dashboard] --format=FORMAT 출력 형식 (html, json, markdown) [기본: html] --verbose, -v 상세 출력 --help, -h 이 도움말 출력 예시: # HTML 대시보드 생성 node scripts/linear-dashboard-generator.cjs --api-key=lin_api_xxx # 30일 기간의 JSON 보고서 생성 node scripts/linear-dashboard-generator.cjs --period=30d --format=json # 커스텀 파일명으로 대시보드 생성 node scripts/linear-dashboard-generator.cjs --output=weekly-report 환경 변수: LINEAR_API_KEY Linear API 키 출력 파일: - HTML: dashboard.html (브라우저에서 열기) - JSON: dashboard.json (데이터 분석용) - Markdown: dashboard.md (문서화용) `); } /** * 메인 함수 */ async function main() { if (options.help) { printUsage(); process.exit(0); } console.log("📊 Linear 프로젝트 대시보드 생성 중...\n"); // API 키 확인 const apiKey = options.apiKey || process.env.LINEAR_API_KEY; if (!apiKey) { console.error( "❌ Linear API 키가 필요합니다. --api-key 옵션을 사용하거나 LINEAR_API_KEY 환경 변수를 설정하세요." ); process.exit(1); } try { // Linear 클라이언트 초기화 const client = new LinearDashboardClient(apiKey); console.log("🔗 Linear API에서 데이터 수집 중..."); // 데이터 수집 const [organizationData, projectStats, cycleData] = await Promise.all([ client.getOrganizationData(), client.getProjectStats(options.period), client.getCycleData(), ]); const data = { organizationData, projectStats, cycleData, }; if (options.verbose) { console.log("📋 수집된 데이터:"); console.log(` - 조직: ${organizationData.organization.name}`); console.log( ` - 팀: ${organizationData.organization.teams.nodes.length}개` ); console.log(` - 이슈: ${projectStats.issues.nodes.length}개`); console.log(` - 프로젝트: ${projectStats.projects.nodes.length}개`); console.log(` - 사이클: ${cycleData.cycles.nodes.length}개`); } // 데이터 분석 console.log("📊 데이터 분석 중..."); const analyzer = new DashboardAnalyzer(data); const summary = analyzer.generateSummary(); // 출력 디렉토리 생성 const outputDir = "reports"; if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } // 대시보드 생성 console.log(`📄 ${options.format.toUpperCase()} 대시보드 생성 중...`); let outputPath; let content; switch (options.format) { case "html": const htmlGenerator = new HtmlDashboardGenerator(summary); content = htmlGenerator.generate(); outputPath = path.join(outputDir, `${options.output}.html`); break; case "json": content = JSON.stringify(summary, null, 2); outputPath = path.join(outputDir, `${options.output}.json`); break; case "markdown": content = generateMarkdownReport(summary); outputPath = path.join(outputDir, `${options.output}.md`); break; default: console.error(`❌ 지원하지 않는 형식: ${options.format}`); process.exit(1); } // 파일 저장 fs.writeFileSync(outputPath, content); console.log("✅ 대시보드 생성 완료!"); console.log(`📁 파일 위치: ${outputPath}`); if (options.format === "html") { console.log("🌐 브라우저에서 열어서 확인하세요."); } // 요약 정보 출력 console.log("\n📈 프로젝트 요약:"); console.log(` 총 이슈: ${summary.summary.totalIssues}개`); console.log( ` 완료율: ${(summary.summary.completionRate * 100).toFixed(1)}%` ); console.log( ` 프로젝트 건강도: ${(summary.summary.projectHealthScore * 100).toFixed(1)}%` ); console.log(` 팀 속도: ${summary.summary.velocity.toFixed(1)} pts/cycle`); } catch (error) { console.error("❌ 대시보드 생성 실패:", error.message); process.exit(1); } } /** * 마크다운 보고서 생성 */ function generateMarkdownReport(summary) { return `# Linear Project Dashboard **Organization**: ${summary.organization} **Period**: ${summary.period} **Generated**: ${new Date(summary.generated).toLocaleString("ko-KR")} ## 📋 Executive Summary - **Total Issues**: ${summary.summary.totalIssues} - **Completion Rate**: ${(summary.summary.completionRate * 100).toFixed(1)}% - **Average Completion Time**: ${summary.summary.avgCompletionTime.toFixed(1)} days - **Project Health Score**: ${(summary.summary.projectHealthScore * 100).toFixed(1)}% - **Team Velocity**: ${summary.summary.velocity.toFixed(1)} points/cycle ## 🎯 Issue Metrics | Metric | Value | |--------|-------| | Total Issues | ${summary.issues.total} | | Completed | ${summary.issues.completed} | | In Progress | ${summary.issues.inProgress} | | Backlog | ${summary.issues.backlog} | | Estimate Accuracy | ${(summary.issues.estimateAccuracy * 100).toFixed(1)}% | ## 🚀 Project Status | Metric | Value | |--------|-------| | Total Projects | ${summary.projects.total} | | Active | ${summary.projects.active} | | Completed | ${summary.projects.completed} | | On Track | ${summary.projects.onTrack} | | Delayed | ${summary.projects.delayed} | ## 👥 Team Performance ${Object.entries(summary.teams.teamsData) .map( ([teamName, team]) => ` ### ${teamName} (${team.key}) - **Members**: ${team.memberCount} - **Total Issues**: ${team.totalIssues} - **Completed**: ${team.completedIssues} - **Velocity**: ${(team.velocity * 100).toFixed(1)}% ` ) .join("")} --- *Generated by Linear Dashboard Generator*`; } // 실행 if (require.main === module) { main(); } module.exports = { LinearDashboardClient, DashboardAnalyzer, HtmlDashboardGenerator, };