✨ 주요 개선사항: - 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>
562 lines
14 KiB
JavaScript
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 };
|