Files
zellyy-finance/scripts/semantic-release-linear-plugin.cjs
hansoo 8343b25439 feat: Stage 2 TypeScript 타입 안전성 개선 - any 타입 83개 → 62개 대폭 감소
 주요 개선사항:
- any 타입 83개에서 62개로 21개 수정 (25% 감소)
- 모든 ESLint 에러 11개 → 0개 완전 해결
- 타입 안전성 대폭 향상으로 런타임 오류 가능성 감소

🔧 수정된 파일들:
• PWADebug.tsx - 사용하지 않는 import들에 _ prefix 추가
• categoryUtils.ts - 불필요한 any 캐스트 제거
• TransactionsHeader.tsx - BudgetData 인터페이스 정의
• storageUtils.ts - generic 타입과 unknown 타입 적용
• 각종 error handler들 - Error | {message?: string} 타입 적용
• test 파일들 - 적절한 mock 인터페이스 정의
• 유틸리티 파일들 - any → unknown 또는 적절한 타입으로 교체

🏆 성과:
- 코드 품질 크게 향상 (280 → 80 문제로 71% 감소)
- TypeScript 컴파일러의 타입 체크 효과성 증대
- 개발자 경험 개선 (IDE 자동완성, 타입 추론 등)

🧹 추가 정리:
- ESLint no-console/no-alert 경고 해결
- Prettier 포맷팅 적용으로 코드 스타일 통일

🎯 다음 단계: 남은 62개 any 타입 계속 개선 예정

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 10:08:51 +09:00

562 lines
14 KiB
JavaScript

#!/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 <prepare|success> <version>"
);
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 };