#!/usr/bin/env node /** * Semantic Release Linear Plugin * Linear 이슈들을 기반으로 릴리즈 노트를 생성하고 Linear에 릴리즈 정보를 동기화 */ const { execSync } = require("child_process"); const fs = require("fs"); const path = require("path"); /** * Linear API 클라이언트 */ class LinearClient { 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 getIssuesByIds(issueIds) { if (!issueIds || issueIds.length === 0) return []; const query = ` query GetIssuesByIds($ids: [String!]!) { issues(filter: { identifier: { in: $ids } }) { nodes { id identifier title description state { id name type } priority labels { nodes { id name color } } assignee { id name email } team { id name key } project { id name } createdAt updatedAt completedAt } } } `; const result = await this.query(query, { ids: issueIds }); return result.issues.nodes; } async createProject(name, description, teamId) { const query = ` mutation CreateProject($input: ProjectCreateInput!) { projectCreate(input: $input) { success project { id name description url } } } `; const input = { name, description, teamId, }; const result = await this.query(query, { input }); return result.projectCreate; } async updateProjectStatus(projectId, completedAt) { const query = ` mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) { projectUpdate(id: $id, input: $input) { success project { id name completedAt } } } `; const input = { completedAt: completedAt || new Date().toISOString(), }; const result = await this.query(query, { id: projectId, input }); return result.projectUpdate; } async addCommentToIssue(issueId, body) { const query = ` mutation CreateComment($input: CommentCreateInput!) { commentCreate(input: $input) { success comment { id body createdAt } } } `; const input = { issueId, body, }; const result = await this.query(query, { input }); return result.commentCreate; } } /** * Git 커밋에서 Linear 이슈 ID 추출 */ function extractLinearIssuesFromCommits(commits) { const issueIds = new Set(); const issueRegex = /(?:ZEL-\d+)/g; commits.forEach((commit) => { const matches = commit.message.match(issueRegex); if (matches) { matches.forEach((match) => issueIds.add(match)); } }); return Array.from(issueIds); } /** * 커밋들을 semantic-release로부터 가져오기 */ function getCommitsSinceLastRelease() { try { // 마지막 태그 찾기 const lastTag = execSync("git describe --tags --abbrev=0", { encoding: "utf8", }).trim(); // 마지막 태그 이후의 커밋들 가져오기 const commits = execSync( `git log ${lastTag}..HEAD --pretty=format:"%H|%s|%an|%ae|%ad"`, { encoding: "utf8", } ).trim(); if (!commits) return []; return commits.split("\n").map((line) => { const [hash, message, author, email, date] = line.split("|"); return { hash, message, author, email, date }; }); } catch (error) { // 첫 번째 릴리즈인 경우 모든 커밋 가져오기 try { const commits = execSync('git log --pretty=format:"%H|%s|%an|%ae|%ad"', { encoding: "utf8", }).trim(); if (!commits) return []; return commits.split("\n").map((line) => { const [hash, message, author, email, date] = line.split("|"); return { hash, message, author, email, date }; }); } catch (err) { console.warn("Unable to get commits:", err.message); return []; } } } /** * Linear 이슈들을 카테고리별로 분류 */ function categorizeIssues(issues) { const categories = { features: [], bugfixes: [], improvements: [], other: [], }; issues.forEach((issue) => { const title = issue.title.toLowerCase(); const labels = issue.labels.nodes.map((label) => label.name.toLowerCase()); if ( title.includes("feat") || title.includes("feature") || labels.includes("feature") ) { categories.features.push(issue); } else if ( title.includes("fix") || title.includes("bug") || labels.includes("bug") ) { categories.bugfixes.push(issue); } else if ( title.includes("improve") || title.includes("enhance") || labels.includes("improvement") ) { categories.improvements.push(issue); } else { categories.other.push(issue); } }); return categories; } /** * Linear 기반 릴리즈 노트 생성 */ function generateLinearReleaseNotes(version, issues, categories) { let notes = `# Release ${version}\n\n`; if (issues.length === 0) { notes += "No Linear issues were referenced in this release.\n"; return notes; } notes += `이번 릴리즈에는 ${issues.length}개의 Linear 이슈가 포함되었습니다.\n\n`; // Features if (categories.features.length > 0) { notes += "## ✨ New Features\n\n"; categories.features.forEach((issue) => { notes += `- **${issue.identifier}**: ${issue.title}\n`; if (issue.assignee) { notes += ` - Assignee: ${issue.assignee.name}\n`; } }); notes += "\n"; } // Bug Fixes if (categories.bugfixes.length > 0) { notes += "## 🐛 Bug Fixes\n\n"; categories.bugfixes.forEach((issue) => { notes += `- **${issue.identifier}**: ${issue.title}\n`; if (issue.assignee) { notes += ` - Assignee: ${issue.assignee.name}\n`; } }); notes += "\n"; } // Improvements if (categories.improvements.length > 0) { notes += "## ⚡ Improvements\n\n"; categories.improvements.forEach((issue) => { notes += `- **${issue.identifier}**: ${issue.title}\n`; if (issue.assignee) { notes += ` - Assignee: ${issue.assignee.name}\n`; } }); notes += "\n"; } // Other if (categories.other.length > 0) { notes += "## 📋 Other Changes\n\n"; categories.other.forEach((issue) => { notes += `- **${issue.identifier}**: ${issue.title}\n`; if (issue.assignee) { notes += ` - Assignee: ${issue.assignee.name}\n`; } }); notes += "\n"; } // Linear 링크 notes += "## 🔗 Linear Issues\n\n"; issues.forEach((issue) => { notes += `- [${issue.identifier}](https://linear.app/zellyy/issue/${issue.identifier}) - ${issue.title}\n`; }); return notes; } /** * Linear에 릴리즈 완료 코멘트 추가 */ async function addReleaseCommentsToIssues(linear, issues, version, releaseUrl) { const releaseComment = `🎉 **릴리즈 완료**: v${version} 이 이슈가 포함된 새로운 버전이 릴리즈되었습니다. **릴리즈 정보:** - 버전: v${version} - 릴리즈 노트: ${releaseUrl} - 배포 시간: ${new Date().toLocaleString("ko-KR")} **다음 단계:** - 프로덕션 배포 확인 - 기능 테스트 수행 - 사용자 피드백 모니터링`; const results = []; for (const issue of issues) { try { const result = await linear.addCommentToIssue(issue.id, releaseComment); if (result.success) { console.log(`✅ Added release comment to ${issue.identifier}`); results.push({ issueId: issue.identifier, success: true }); } else { console.warn(`⚠️ Failed to add comment to ${issue.identifier}`); results.push({ issueId: issue.identifier, success: false }); } } catch (error) { console.error( `❌ Error adding comment to ${issue.identifier}:`, error.message ); results.push({ issueId: issue.identifier, success: false, error: error.message, }); } } return results; } /** * 릴리즈 메타데이터 저장 */ function saveReleaseMetadata(version, issues, categories, releaseNotes) { const releasesDir = "releases"; if (!fs.existsSync(releasesDir)) { fs.mkdirSync(releasesDir, { recursive: true }); } const metadata = { version, releasedAt: new Date().toISOString(), issueCount: issues.length, issues: issues.map((issue) => ({ id: issue.identifier, title: issue.title, assignee: issue.assignee?.name, team: issue.team?.name, state: issue.state?.name, })), categories: { features: categories.features.length, bugfixes: categories.bugfixes.length, improvements: categories.improvements.length, other: categories.other.length, }, releaseNotes, }; const metadataPath = path.join(releasesDir, `v${version}-metadata.json`); fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); console.log(`📝 Release metadata saved: ${metadataPath}`); return metadataPath; } /** * Semantic Release 플러그인 인터페이스 */ async function prepare(pluginConfig, context) { const { nextRelease, logger } = context; const version = nextRelease.version; logger.log(`🔗 Linear Integration: Preparing release v${version}`); try { // Linear API 키 확인 const apiKey = process.env.LINEAR_API_KEY; if (!apiKey) { logger.warn("LINEAR_API_KEY not found, skipping Linear integration"); return; } const linear = new LinearClient(apiKey); // 커밋에서 Linear 이슈 ID 추출 const commits = getCommitsSinceLastRelease(); const issueIds = extractLinearIssuesFromCommits(commits); if (issueIds.length === 0) { logger.log("No Linear issues found in commits"); return; } logger.log( `Found ${issueIds.length} Linear issues: ${issueIds.join(", ")}` ); // Linear에서 이슈 정보 가져오기 const issues = await linear.getIssuesByIds(issueIds); const categories = categorizeIssues(issues); // Linear 기반 릴리즈 노트 생성 const linearNotes = generateLinearReleaseNotes(version, issues, categories); // 기존 릴리즈 노트에 Linear 정보 추가 if (nextRelease.notes) { nextRelease.notes += "\n\n" + linearNotes; } else { nextRelease.notes = linearNotes; } // 메타데이터 저장 saveReleaseMetadata(version, issues, categories, linearNotes); logger.log(`✅ Linear integration prepared for v${version}`); } catch (error) { logger.error("❌ Linear integration failed:", error.message); // 에러가 발생해도 릴리즈는 계속 진행 } } async function success(pluginConfig, context) { const { nextRelease, releases, logger } = context; const version = nextRelease.version; logger.log(`🎉 Linear Integration: Release v${version} successful`); try { // Linear API 키 확인 const apiKey = process.env.LINEAR_API_KEY; if (!apiKey) { logger.warn("LINEAR_API_KEY not found, skipping Linear integration"); return; } // GitHub 릴리즈 URL 찾기 const githubRelease = releases.find( (release) => release.pluginName === "@semantic-release/github" ); const releaseUrl = githubRelease?.url || `https://github.com/zellycloud/zellyy-finance/releases/tag/v${version}`; const linear = new LinearClient(apiKey); // 커밋에서 Linear 이슈 ID 추출 const commits = getCommitsSinceLastRelease(); const issueIds = extractLinearIssuesFromCommits(commits); if (issueIds.length === 0) { logger.log("No Linear issues to update"); return; } // Linear에서 이슈 정보 가져오기 const issues = await linear.getIssuesByIds(issueIds); // 각 이슈에 릴리즈 완료 코멘트 추가 const commentResults = await addReleaseCommentsToIssues( linear, issues, version, releaseUrl ); const successCount = commentResults.filter((r) => r.success).length; logger.log( `✅ Added release comments to ${successCount}/${issues.length} Linear issues` ); } catch (error) { logger.error("❌ Linear post-release integration failed:", error.message); // 에러가 발생해도 릴리즈는 이미 완료된 상태 } } // CLI로 직접 실행하는 경우 if (require.main === module) { const action = process.argv[2]; const version = process.argv[3]; if (!action || !version) { console.error( "Usage: node semantic-release-linear-plugin.cjs " ); process.exit(1); } const mockContext = { nextRelease: { version }, releases: [], logger: { log: console.log, warn: console.warn, error: console.error, }, }; if (action === "prepare") { prepare({}, mockContext).catch(console.error); } else if (action === "success") { success({}, mockContext).catch(console.error); } else { console.error('Unknown action. Use "prepare" or "success"'); process.exit(1); } } module.exports = { prepare, success };