#!/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 `