#!/usr/bin/env node /** * Linear GitHub 앱 연동 및 기본 연결 설정 스크립트 * Linear와 GitHub 간의 기본 연동을 설정하고 검증 */ const { execSync } = require("child_process"); const fs = require("fs"); const path = require("path"); // Simple command line argument parsing const args = process.argv.slice(2); const options = { linearApiKey: null, githubToken: null, setup: args.includes("--setup"), verify: args.includes("--verify"), configure: args.includes("--configure"), help: args.includes("--help") || args.includes("-h"), }; // Extract API keys from arguments const linearApiIndex = args.findIndex((arg) => arg.startsWith("--linear-api=")); if (linearApiIndex !== -1) { options.linearApiKey = args[linearApiIndex].split("=")[1]; } const githubTokenIndex = args.findIndex((arg) => arg.startsWith("--github-token=") ); if (githubTokenIndex !== -1) { options.githubToken = args[githubTokenIndex].split("=")[1]; } /** * 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 getWorkspaceInfo() { const query = ` query GetWorkspaceInfo { organization { id name urlKey teams { nodes { id name key issueCount } } } viewer { id name email } } `; return await this.query(query); } async createWebhook(url, teamId) { const query = ` mutation CreateWebhook($url: String!, $teamId: String!) { webhookCreate(input: { url: $url teamId: $teamId allPublicTeams: false resourceTypes: ["Issue", "IssueLabel", "Comment"] }) { success webhook { id url enabled } } } `; return await this.query(query, { url, teamId }); } async listWebhooks() { const query = ` query ListWebhooks { webhooks { nodes { id url enabled resourceTypes team { name key } } } } `; return await this.query(query); } } /** * GitHub API 클라이언트 */ class GitHubClient { constructor(token, repo) { this.token = token; this.repo = repo; this.baseUrl = "https://api.github.com"; } async request(endpoint, method = "GET", data = null) { const url = `${this.baseUrl}${endpoint}`; const options = { method, headers: { Authorization: `token ${this.token}`, Accept: "application/vnd.github.v3+json", "User-Agent": "Zellyy-Finance-Linear-Integration", }, }; if (data) { options.headers["Content-Type"] = "application/json"; options.body = JSON.stringify(data); } try { const response = await fetch(url, options); if (!response.ok) { const errorData = await response.text(); throw new Error(`GitHub API Error: ${response.status} ${errorData}`); } return await response.json(); } catch (error) { throw new Error(`GitHub API call failed: ${error.message}`); } } async getRepository() { return await this.request(`/repos/${this.repo}`); } async createWebhook(config) { return await this.request(`/repos/${this.repo}/hooks`, "POST", config); } async listWebhooks() { return await this.request(`/repos/${this.repo}/hooks`); } async createSecret(name, encryptedValue) { return await this.request( `/repos/${this.repo}/actions/secrets/${name}`, "PUT", { encrypted_value: encryptedValue, } ); } } /** * 환경 설정 검증 */ function validateEnvironment() { console.log("🔍 환경 설정 검증 중..."); const requiredFiles = [ ".github/workflows/linear-integration.yml", "scripts/linear-sync.cjs", "scripts/linear-comment.cjs", ".env.linear.example", ]; const missingFiles = []; requiredFiles.forEach((file) => { if (!fs.existsSync(file)) { missingFiles.push(file); } }); if (missingFiles.length > 0) { console.error("❌ 필수 파일들이 누락되었습니다:"); missingFiles.forEach((file) => console.error(` - ${file}`)); return false; } console.log("✅ 모든 필수 파일이 존재합니다."); return true; } /** * Git 리포지토리 정보 확인 */ function getGitInfo() { try { // GitHub remote이 있는지 먼저 확인 let remoteUrl; try { remoteUrl = execSync("git config --get remote.github.url", { encoding: "utf8", }).trim(); } catch { // github remote가 없으면 origin 확인 remoteUrl = execSync("git config --get remote.origin.url", { encoding: "utf8", }).trim(); } // GitHub URL에서 owner/repo 추출 const match = remoteUrl.match(/github\.com[/:](.*?)\/(.*)(?:\.git)?$/); if (match) { const owner = match[1]; const repo = match[2].replace(".git", ""); return { owner, repo, fullName: `${owner}/${repo}` }; } // GitHub remote가 아닌 경우 수동으로 GitHub 정보 사용 console.log( "ℹ️ GitHub remote가 감지되지 않았습니다. zellycloud/zellyy-finance를 사용합니다." ); return { owner: "zellycloud", repo: "zellyy-finance", fullName: "zellycloud/zellyy-finance", }; } catch (error) { throw new Error(`Git 정보를 가져올 수 없습니다: ${error.message}`); } } /** * Linear 워크스페이스 정보 출력 */ async function displayLinearInfo(linear) { console.log("\n📋 Linear 워크스페이스 정보:"); try { const info = await linear.getWorkspaceInfo(); console.log( ` 조직: ${info.organization.name} (${info.organization.urlKey})` ); console.log(` 사용자: ${info.viewer.name} (${info.viewer.email})`); console.log(` 팀 수: ${info.organization.teams.nodes.length}`); console.log("\n👥 팀 목록:"); info.organization.teams.nodes.forEach((team) => { console.log(` - ${team.name} (${team.key}): ${team.issueCount}개 이슈`); }); return info; } catch (error) { console.error("❌ Linear 정보를 가져올 수 없습니다:", error.message); throw error; } } /** * GitHub 리포지토리 정보 출력 */ async function displayGitHubInfo(github) { console.log("\n📁 GitHub 리포지토리 정보:"); try { const repo = await github.getRepository(); console.log(` 이름: ${repo.full_name}`); console.log(` 설명: ${repo.description || "설명 없음"}`); console.log(` 언어: ${repo.language || "N/A"}`); console.log(` 프라이빗: ${repo.private ? "Yes" : "No"}`); console.log(` 기본 브랜치: ${repo.default_branch}`); return repo; } catch (error) { console.error("❌ GitHub 정보를 가져올 수 없습니다:", error.message); throw error; } } /** * 환경 변수 파일 생성 */ function createEnvFile(linearInfo, gitInfo) { console.log("\n📝 환경 변수 파일 생성 중..."); const envContent = `# Linear GitHub 연동 설정 # 이 파일을 .env로 복사하고 실제 값으로 업데이트하세요 # Linear API 설정 LINEAR_API_KEY=${options.linearApiKey || "your-linear-api-key"} LINEAR_WORKSPACE_ID=${linearInfo.organization.id} LINEAR_TEAM_ID=${linearInfo.organization.teams.nodes[0]?.id || "your-team-id"} # GitHub 설정 GITHUB_TOKEN=${options.githubToken || "your-github-token"} GITHUB_REPOSITORY=${gitInfo.fullName} # 웹훅 URL (GitHub Actions에서 자동 설정) LINEAR_WEBHOOK_URL=https://api.github.com/repos/${gitInfo.fullName}/dispatches # 디버그 모드 DEBUG=false `; const envFile = ".env.linear"; fs.writeFileSync(envFile, envContent); console.log(`✅ 환경 변수 파일이 생성되었습니다: ${envFile}`); console.log(" 이 파일을 .env로 복사하거나 기존 .env에 내용을 추가하세요."); } /** * GitHub Secrets 설정 안내 */ function displaySecretsGuide() { console.log("\n🔐 GitHub Secrets 설정 안내:"); console.log("다음 secrets을 GitHub 리포지토리에 추가해야 합니다:"); console.log(""); console.log("1. Repository Settings → Secrets and variables → Actions"); console.log('2. "New repository secret" 클릭'); console.log("3. 다음 secrets 추가:"); console.log(" - Name: LINEAR_API_KEY"); console.log(` - Value: ${options.linearApiKey || "your-linear-api-key"}`); console.log(""); console.log("선택적 secrets:"); console.log(" - SLACK_BOT_TOKEN (Slack 연동용)"); console.log(" - SLACK_WEBHOOK_URL (Slack 알림용)"); } /** * Linear 웹훅 설정 */ async function setupLinearWebhooks(linear, gitInfo) { console.log("\n🔗 Linear 웹훅 설정 중..."); try { // 기존 웹훅 확인 const existingWebhooks = await linear.listWebhooks(); const githubWebhook = existingWebhooks.webhooks.nodes.find( (webhook) => webhook.url.includes("github.com") || webhook.url.includes(gitInfo.fullName) ); if (githubWebhook) { console.log("✅ GitHub 웹훅이 이미 설정되어 있습니다:"); console.log(` URL: ${githubWebhook.url}`); console.log(` 활성화: ${githubWebhook.enabled ? "Yes" : "No"}`); console.log(` 팀: ${githubWebhook.team?.name || "All teams"}`); return; } console.log("ℹ️ Linear 웹훅 설정은 수동으로 진행해야 합니다:"); console.log("1. Linear → Settings → API → Webhooks"); console.log('2. "Create webhook" 클릭'); console.log( `3. URL: https://api.github.com/repos/${gitInfo.fullName}/dispatches` ); console.log("4. Resource types: Issue, Comment, IssueLabel 선택"); console.log("5. Team: 원하는 팀 선택 (또는 모든 팀)"); } catch (error) { console.error("❌ 웹훅 설정 중 오류 발생:", error.message); } } /** * GitHub Actions 워크플로우 검증 */ function verifyWorkflow() { console.log("\n🔧 GitHub Actions 워크플로우 검증 중..."); const workflowPath = ".github/workflows/linear-integration.yml"; if (!fs.existsSync(workflowPath)) { console.error("❌ Linear integration 워크플로우를 찾을 수 없습니다."); return false; } const workflow = fs.readFileSync(workflowPath, "utf8"); const checks = [ { name: "Pull request triggers", pattern: /on:\s*\n.*pull_request:/s }, { name: "Push triggers", pattern: /push:/ }, { name: "Linear ID extraction", pattern: /extract.*linear.*id/i }, { name: "Environment variables", pattern: /LINEAR_API_KEY/ }, { name: "Sync scripts", pattern: /linear-sync/ }, { name: "Comment scripts", pattern: /linear-comment/ }, ]; let allPassed = true; checks.forEach((check) => { if (check.pattern.test(workflow)) { console.log(` ✅ ${check.name}`); } else { console.log(` ❌ ${check.name} 누락`); allPassed = false; } }); return allPassed; } /** * 통합 테스트 실행 */ async function runIntegrationTest() { console.log("\n🧪 통합 테스트 실행 중..."); try { const testCommand = `node scripts/test-linear-integration.cjs --api-key=${options.linearApiKey}`; const result = execSync(testCommand, { encoding: "utf8", stdio: "pipe" }); console.log("✅ 통합 테스트 성공"); console.log(result); return true; } catch (error) { console.error("❌ 통합 테스트 실패:", error.message); return false; } } /** * 설정 완료 안내 */ function displaySetupComplete() { console.log("\n🎉 Linear GitHub 연동 설정이 완료되었습니다!"); console.log(""); console.log("다음 단계:"); console.log("1. GitHub Secrets에 LINEAR_API_KEY 추가"); console.log("2. Linear에서 웹훅 설정 (위의 안내 참조)"); console.log("3. Pull Request 생성하여 연동 테스트"); console.log(""); console.log("테스트 방법:"); console.log( "1. 새 브랜치 생성: git checkout -b feature/test-linear-integration" ); console.log( '2. 커밋 메시지에 Linear 이슈 ID 포함: git commit -m "feat: test integration [ZEL-1]"' ); console.log( '3. Pull Request 제목에 Linear 이슈 ID 포함: "Test Linear integration (ZEL-1)"' ); console.log("4. GitHub Actions 로그에서 연동 동작 확인"); } /** * 사용법 출력 */ function printUsage() { console.log(` 🔧 Linear GitHub 연동 설정 도구 사용법: node scripts/linear-github-setup.cjs [옵션] 옵션: --setup 전체 설정 실행 --verify 기존 설정 검증 --configure 환경 설정만 생성 --linear-api=KEY Linear API 키 지정 --github-token=TOKEN GitHub 토큰 지정 --help, -h 이 도움말 출력 예시: # 전체 설정 실행 node scripts/linear-github-setup.cjs --setup --linear-api=lin_api_xxx # 기존 설정 검증 node scripts/linear-github-setup.cjs --verify # 환경 설정만 생성 node scripts/linear-github-setup.cjs --configure --linear-api=lin_api_xxx 환경 변수: LINEAR_API_KEY Linear API 키 GITHUB_TOKEN GitHub 개인 액세스 토큰 필수 조건: - Git 리포지토리 (GitHub remote 설정됨) - Linear API 키 - GitHub 개인 액세스 토큰 (repo 권한) `); } /** * 메인 함수 */ async function main() { if (options.help) { printUsage(); process.exit(0); } console.log("🚀 Linear GitHub 연동 설정 도구"); console.log("=====================================\n"); // 환경 검증 if (!validateEnvironment()) { console.error( "❌ 환경 설정이 올바르지 않습니다. 먼저 필수 파일들을 생성해주세요." ); process.exit(1); } // Git 정보 확인 let gitInfo; try { gitInfo = getGitInfo(); console.log(`✅ Git 리포지토리: ${gitInfo.fullName}`); } catch (error) { console.error("❌", error.message); process.exit(1); } // API 키 설정 const linearApiKey = options.linearApiKey || process.env.LINEAR_API_KEY; const githubToken = options.githubToken || process.env.GITHUB_TOKEN; if (!linearApiKey) { console.error( "❌ Linear API 키가 필요합니다. --linear-api 옵션을 사용하거나 LINEAR_API_KEY 환경 변수를 설정하세요." ); process.exit(1); } // 클라이언트 초기화 const linear = new LinearClient(linearApiKey); const github = githubToken ? new GitHubClient(githubToken, gitInfo.fullName) : null; // 검증 모드 if (options.verify) { console.log("🔍 기존 설정 검증 중...\n"); const linearInfo = await displayLinearInfo(linear); if (github) await displayGitHubInfo(github); const workflowValid = verifyWorkflow(); const testPassed = await runIntegrationTest(); if (workflowValid && testPassed) { console.log("\n✅ 모든 검증이 성공했습니다!"); } else { console.log("\n❌ 일부 검증이 실패했습니다."); process.exit(1); } return; } // 설정 모드 if (options.setup || options.configure) { console.log("⚙️ Linear GitHub 연동 설정 시작...\n"); // Linear 정보 확인 const linearInfo = await displayLinearInfo(linear); // GitHub 정보 확인 (토큰이 있는 경우) if (github) { await displayGitHubInfo(github); } // 환경 설정 파일 생성 createEnvFile(linearInfo, gitInfo); if (options.setup) { // 웹훅 설정 await setupLinearWebhooks(linear, gitInfo); // Secrets 안내 displaySecretsGuide(); // 워크플로우 검증 verifyWorkflow(); // 완료 안내 displaySetupComplete(); } return; } // 기본 동작: 정보 표시 const linearInfo = await displayLinearInfo(linear); if (github) await displayGitHubInfo(github); console.log("\n💡 다음 명령어로 설정을 진행하세요:"); console.log( ` node scripts/linear-github-setup.cjs --setup --linear-api=${linearApiKey}` ); } // 실행 if (require.main === module) { main().catch((error) => { console.error("\n❌ 설정 중 오류 발생:", error.message); process.exit(1); }); } module.exports = { LinearClient, GitHubClient, validateEnvironment };