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>
This commit is contained in:
561
scripts/semantic-release-linear-plugin.cjs
Normal file
561
scripts/semantic-release-linear-plugin.cjs
Normal file
@@ -0,0 +1,561 @@
|
||||
#!/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 };
|
||||
Reference in New Issue
Block a user