#!/usr/bin/env node /** * Linear 릴리즈 준비 스크립트 * semantic-release와 연동하여 릴리즈 노트에 Linear 이슈 포함 */ const fs = require("fs"); const path = require("path"); // Linear 클라이언트 (동일한 구조) class LinearClient { constructor({ apiKey }) { this.apiKey = apiKey; this.baseUrl = "https://api.linear.app/graphql"; } async query(query, variables = {}) { 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; } async getRecentIssues(daysBack = 30) { const sinceDate = new Date(); sinceDate.setDate(sinceDate.getDate() - daysBack); const query = ` query GetRecentIssues($since: DateTimeOrDuration!) { issues( filter: { updatedAt: { gte: $since }, state: { name: { eq: "Done" } } }, orderBy: updatedAt ) { nodes { id identifier title description url state { name } priority labels { nodes { name color } } assignee { name email } team { name } project { name } completedAt createdAt updatedAt } } } `; const result = await this.query(query, { since: sinceDate.toISOString() }); return result.issues.nodes; } async getProjectMilestones() { const query = ` query GetProjectMilestones { projects { nodes { id name milestones { nodes { id name targetDate } } } } } `; const result = await this.query(query); return result.projects.nodes; } async archiveIssues(issueIds) { const query = ` mutation ArchiveIssues($issueIds: [String!]!) { issueArchive(input: { ids: $issueIds }) { success } } `; const result = await this.query(query, { issueIds }); return result.issueArchive; } } const version = process.argv[2]; const apiKey = process.env.LINEAR_API_KEY; if (!version) { console.error("Usage: node linear-release-prep.js "); process.exit(1); } if (!apiKey) { console.error("Error: LINEAR_API_KEY environment variable not set"); process.exit(1); } const linear = new LinearClient({ apiKey }); /** * 이슈를 카테고리별로 분류 */ function categorizeIssues(issues) { const categories = { features: [], bugfixes: [], improvements: [], tasks: [], other: [], }; issues.forEach((issue) => { const labels = issue.labels.nodes.map((l) => l.name.toLowerCase()); const title = issue.title.toLowerCase(); if (labels.includes("feature") || title.includes("feat")) { categories.features.push(issue); } else if ( labels.includes("bug") || labels.includes("bugfix") || title.includes("fix") ) { categories.bugfixes.push(issue); } else if ( labels.includes("improvement") || labels.includes("enhancement") ) { categories.improvements.push(issue); } else if (labels.includes("task") || labels.includes("chore")) { categories.tasks.push(issue); } else { categories.other.push(issue); } }); return categories; } /** * 릴리즈 노트 생성 */ function generateReleaseNotes(issues, version) { const categorized = categorizeIssues(issues); const releaseDate = new Date().toLocaleDateString("ko-KR"); let notes = `# Zellyy Finance v${version}\n\n`; notes += `**릴리즈 날짜**: ${releaseDate}\n`; notes += `**완료된 이슈**: ${issues.length}개\n\n`; // 새로운 기능 if (categorized.features.length > 0) { notes += `## ✨ 새로운 기능\n\n`; categorized.features.forEach((issue) => { notes += `- ${issue.title} ([${issue.identifier}](${issue.url}))\n`; }); notes += "\n"; } // 버그 수정 if (categorized.bugfixes.length > 0) { notes += `## 🐛 버그 수정\n\n`; categorized.bugfixes.forEach((issue) => { notes += `- ${issue.title} ([${issue.identifier}](${issue.url}))\n`; }); notes += "\n"; } // 개선사항 if (categorized.improvements.length > 0) { notes += `## ⚡ 개선사항\n\n`; categorized.improvements.forEach((issue) => { notes += `- ${issue.title} ([${issue.identifier}](${issue.url}))\n`; }); notes += "\n"; } // 기타 작업 if (categorized.tasks.length > 0) { notes += `## 🔧 기타 작업\n\n`; categorized.tasks.forEach((issue) => { notes += `- ${issue.title} ([${issue.identifier}](${issue.url}))\n`; }); notes += "\n"; } // 기타 if (categorized.other.length > 0) { notes += `## 📋 기타\n\n`; categorized.other.forEach((issue) => { notes += `- ${issue.title} ([${issue.identifier}](${issue.url}))\n`; }); notes += "\n"; } // 기여자 정보 const contributors = [ ...new Set(issues.map((i) => i.assignee?.name).filter(Boolean)), ]; if (contributors.length > 0) { notes += `## 👥 기여자\n\n`; contributors.forEach((contributor) => { notes += `- ${contributor}\n`; }); notes += "\n"; } notes += `---\n\n`; notes += `전체 변경사항은 [GitHub 릴리즈](https://github.com/zellyy-finance/zellyy-finance/releases/tag/v${version})에서 확인할 수 있습니다.\n`; return notes; } /** * 메타데이터 생성 */ function generateMetadata(issues, version) { const categorized = categorizeIssues(issues); return { version, releaseDate: new Date().toISOString(), totalIssues: issues.length, categories: { features: categorized.features.length, bugfixes: categorized.bugfixes.length, improvements: categorized.improvements.length, tasks: categorized.tasks.length, other: categorized.other.length, }, issues: issues.map((issue) => ({ id: issue.identifier, title: issue.title, url: issue.url, team: issue.team?.name, project: issue.project?.name, assignee: issue.assignee?.name, labels: issue.labels.nodes.map((l) => l.name), priority: issue.priority, completedAt: issue.completedAt, createdAt: issue.createdAt, })), stats: { averageCompletionTime: calculateAverageCompletionTime(issues), teamDistribution: getTeamDistribution(issues), priorityDistribution: getPriorityDistribution(issues), }, }; } function calculateAverageCompletionTime(issues) { const completionTimes = issues .filter((i) => i.completedAt && i.createdAt) .map((i) => { const created = new Date(i.createdAt); const completed = new Date(i.completedAt); return (completed - created) / (1000 * 60 * 60 * 24); // days }); if (completionTimes.length === 0) return 0; return completionTimes.reduce((a, b) => a + b, 0) / completionTimes.length; } function getTeamDistribution(issues) { const distribution = {}; issues.forEach((issue) => { const team = issue.team?.name || "Unassigned"; distribution[team] = (distribution[team] || 0) + 1; }); return distribution; } function getPriorityDistribution(issues) { const distribution = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 }; issues.forEach((issue) => { const priority = issue.priority || 0; distribution[priority] = (distribution[priority] || 0) + 1; }); return distribution; } /** * 릴리즈 준비 메인 함수 */ async function prepareRelease() { try { console.log(`🚀 Preparing release v${version}...`); // 완료된 이슈 조회 console.log("📋 Fetching completed Linear issues..."); const issues = await linear.getRecentIssues(30); console.log(`Found ${issues.length} completed issues in the last 30 days`); if (issues.length === 0) { console.log("⚠️ No completed issues found for this release"); return; } // 디렉토리 생성 const releasesDir = path.join(process.cwd(), "releases"); if (!fs.existsSync(releasesDir)) { fs.mkdirSync(releasesDir, { recursive: true }); } // 릴리즈 노트 생성 console.log("📝 Generating release notes..."); const releaseNotes = generateReleaseNotes(issues, version); // 릴리즈 노트 파일 저장 const notesPath = path.join(releasesDir, `v${version}-notes.md`); fs.writeFileSync(notesPath, releaseNotes); console.log(`✅ Release notes saved: ${notesPath}`); // 메타데이터 생성 console.log("🔢 Generating metadata..."); const metadata = generateMetadata(issues, version); // 메타데이터 파일 저장 const metadataPath = path.join(releasesDir, `v${version}-metadata.json`); fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); console.log(`✅ Metadata saved: ${metadataPath}`); // CHANGELOG.md 업데이트 console.log("📄 Updating CHANGELOG.md..."); updateChangelog(releaseNotes); // 릴리즈 요약 출력 console.log("\n📊 Release Summary:"); console.log(` Version: v${version}`); console.log(` Total Issues: ${issues.length}`); console.log(` Features: ${metadata.categories.features}`); console.log(` Bug Fixes: ${metadata.categories.bugfixes}`); console.log(` Improvements: ${metadata.categories.improvements}`); console.log(` Tasks: ${metadata.categories.tasks}`); console.log( ` Average Completion Time: ${metadata.stats.averageCompletionTime.toFixed(1)} days` ); console.log("\n✅ Release preparation completed successfully!"); } catch (error) { console.error("❌ Failed to prepare release:", error.message); process.exit(1); } } /** * CHANGELOG.md 업데이트 */ function updateChangelog(releaseNotes) { const changelogPath = "CHANGELOG.md"; try { let changelog = ""; if (fs.existsSync(changelogPath)) { changelog = fs.readFileSync(changelogPath, "utf8"); } else { changelog = "# Changelog\n\n이 파일은 Zellyy Finance의 모든 주요 변경사항을 기록합니다.\n\n"; } // 새 릴리즈 노트를 맨 위에 추가 const lines = changelog.split("\n"); const titleIndex = lines.findIndex((line) => line.startsWith("# ")); if (titleIndex !== -1) { // 제목 다음에 새 릴리즈 노트 삽입 lines.splice(titleIndex + 2, 0, releaseNotes); changelog = lines.join("\n"); } else { // 제목이 없으면 처음에 추가 changelog = releaseNotes + "\n" + changelog; } fs.writeFileSync(changelogPath, changelog); console.log(`✅ Updated ${changelogPath}`); } catch (error) { console.warn("⚠️ Failed to update CHANGELOG.md:", error.message); } } // 실행 prepareRelease();