feat: 사전 투표창 + 홈 .pen 정밀 매칭 + Poll API · Skeleton 인프라 #3
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Gemini Code Review | |
| on: | |
| pull_request: | |
| branches: | |
| - develop | |
| - main | |
| - master | |
| types: [opened, synchronize] | |
| concurrency: | |
| group: gemini-code-review-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| jobs: | |
| code-review: | |
| if: github.event.pull_request.draft == false | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| - name: Install GoogleGenerativeAI | |
| run: npm install @google/generative-ai | |
| - name: Get PR Context and Filtered Git Diff | |
| id: get_diff | |
| run: | | |
| git fetch origin "${{ github.event.pull_request.base.ref }}" | |
| git fetch origin "${{ github.event.pull_request.head.ref }}" | |
| git diff "origin/${{ github.event.pull_request.base.ref }}"..."origin/${{ github.event.pull_request.head.ref }}" -- "*.swift" > diff.txt | |
| if [ ! -s diff.txt ]; then | |
| echo "skip_review=true" >> $GITHUB_OUTPUT | |
| else | |
| echo "skip_review=false" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Parse Diff for Valid Lines and Annotate | |
| if: steps.get_diff.outputs.skip_review == 'false' | |
| uses: actions/github-script@v7 | |
| id: parse_diff | |
| with: | |
| script: | | |
| const fs = require("fs"); | |
| const diff = fs.readFileSync("diff.txt", "utf8"); | |
| const validLines = {}; | |
| const lineContentMap = {}; | |
| const annotatedLines = []; | |
| let currentFile = null; | |
| let lineNum = 0; | |
| for (const line of diff.split("\n")) { | |
| if (line.startsWith("diff --git")) { | |
| const match = line.match(/b\/(.+)$/); | |
| if (match) currentFile = match[1]; | |
| annotatedLines.push(line); | |
| continue; | |
| } | |
| if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("index ")) { | |
| annotatedLines.push(line); | |
| continue; | |
| } | |
| if (line.startsWith("@@") && currentFile) { | |
| const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/); | |
| if (match) lineNum = parseInt(match[1]); | |
| annotatedLines.push(line); | |
| continue; | |
| } | |
| if (!currentFile) { | |
| annotatedLines.push(line); | |
| continue; | |
| } | |
| if (line.startsWith("+") && !line.startsWith("+++")) { | |
| if (!validLines[currentFile]) validLines[currentFile] = new Set(); | |
| validLines[currentFile].add(lineNum); | |
| if (!lineContentMap[currentFile]) lineContentMap[currentFile] = {}; | |
| lineContentMap[currentFile][lineNum] = line.substring(1).trim(); | |
| annotatedLines.push(`[LINE ${lineNum}] ${line}`); | |
| lineNum++; | |
| } else if (line.startsWith("-") && !line.startsWith("---")) { | |
| annotatedLines.push(`[DEL] ${line}`); | |
| } else { | |
| annotatedLines.push(`[CTX ${lineNum}] ${line}`); | |
| lineNum++; | |
| } | |
| } | |
| const serializable = {}; | |
| for (const [file, lines] of Object.entries(validLines)) { | |
| serializable[file] = [...lines].sort((a, b) => a - b); | |
| } | |
| fs.writeFileSync("valid_lines.json", JSON.stringify(serializable)); | |
| fs.writeFileSync("line_content_map.json", JSON.stringify(lineContentMap)); | |
| fs.writeFileSync("annotated_diff.txt", annotatedLines.join("\n")); | |
| - name: Run Gemini Review | |
| if: steps.get_diff.outputs.skip_review == 'false' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require("fs"); | |
| const annotated_diff = fs.readFileSync("annotated_diff.txt", "utf8"); | |
| const pr_title = context.payload.pull_request.title; | |
| const pr_body = context.payload.pull_request.body || "내용 없음"; | |
| const { GoogleGenerativeAI } = require("@google/generative-ai"); | |
| const genAI = new GoogleGenerativeAI("${{ secrets.GEMINI_API_KEY }}"); | |
| const model = genAI.getGenerativeModel({ | |
| model: "gemini-2.5-flash", | |
| generationConfig: { | |
| responseMimeType: "application/json", | |
| }, | |
| }); | |
| const prompt = `You are a senior iOS engineer performing a code review on a Swift 6 / SwiftUI / TCA 1.25 multi-module Clean Architecture project built with Tuist 4. | |
| [Tone and Style Guidelines] | |
| - Do NOT include unnecessary praise, greetings, or overly verbose explanations. | |
| - Do NOT provide unsolicited CS insights (컴퓨터 과학적 통찰) or related interview questions. | |
| - Provide concise, objective, and well-organized feedback suitable for immediate practical use. | |
| CRITICAL LINE NUMBER RULES (MUST FOLLOW): | |
| - Each diff line is annotated with [LINE N] for added lines or [CTX N] for context lines. | |
| - You MUST only comment on [LINE N] lines (added/modified code). NEVER comment on [CTX] or [DEL] lines. | |
| - Use the EXACT number N shown in the [LINE N] annotation. Do NOT compute line numbers yourself. | |
| - The "code_snippet" field must contain the actual code from that [LINE N] line. | |
| [PR Context] | |
| Title: ${pr_title} | |
| Description: ${pr_body} | |
| [Project Architecture] | |
| - Stack: Swift 6, SwiftUI, TCA 1.25, Tuist 4 (multi-module) | |
| - Layer dependency: Presentation → Domain ← Data, Network is only referenced by Data | |
| - Deployment target: iOS 26.0 (iPhone only) | |
| - Source roots: Projects/App, Projects/Presentation, Projects/Domain/{Entity,UseCase,DomainInterface,DataInterface}, Projects/Data/{Model,Repository,API,Service}, Projects/Network/*, Projects/Shared/* | |
| [Review Criteria] | |
| 1. TCA Convention: Verify @Reducer + @ObservableState usage; Action naming describes events that occurred (e.g., xxxButtonTapped, xxxResponse), NOT intended effects (e.g., performLogin, loadData); Effect is .none when no side effect and .run for async work; shared logic lives in private methods, NOT shared Actions; Effect.run must NOT capture entire @ObservableState (extract needed values first); Reducer must NOT perform CPU-intensive work (offload to Effect); Store.scope must use stored property paths only (no computed transforms); Navigation uses @Reducer enum; transient UI state (hover, focus, animation) stays in SwiftUI @State, not TCA State. | |
| 2. Module Architecture: Respect Presentation → Domain ← Data dependency direction; Network is only imported by Data; module boundaries expose protocols (DomainInterface / DataInterface); DTO-to-Entity mapping stays in Data layer. | |
| 3. SwiftUI Convention: SubViews are structs (NOT @ViewBuilder functions); use @Binding when a SubView mutates parent @State; no "View" suffix in View names (unless clarity requires it); use .frame(maxWidth/maxHeight: .infinity) instead of Spacer() for simple expansion; required props via init, optional props via ViewModifier-style functions. | |
| 4. Swift Code Quality: guard early return with shorthand optional binding (guard let value else { ... }) followed by a blank line; final class by default; private first (avoid fileprivate unless required); never force unwrap; operator line break puts operator at the start of the next line; function params line-break with closing paren on its own line; ternary for simple return/assignment only, split on '?'; [weak self] + guard let self else { return } in closures; constant groups as private enum (Metric/Font/Constant), NOT struct; empty collection literals ([] / [:]); indent 4 spaces; 120-char line limit. | |
| 5. Actionable Feedback: When improvement is needed you MUST provide a concrete Swift fix using GitHub's \`\`\`suggestion block. | |
| [Severity Prefix] | |
| Each comment body MUST start with a severity tag on its own line, then a blank line, then the actual comment: | |
| - 🔴 [P1] Critical: force-unwrap crash risk, retain cycles / memory leaks, heavy or blocking work inside a Reducer, main-thread blocking | |
| - 🟠 [P2] Major: module dependency-direction violations (e.g., Domain importing Data), Effect.run capturing entire @ObservableState, sharing logic through Actions, Store.scope with computed property, serious concurrency or error-mapping issues | |
| - 🟡 [P3] Minor: Action naming that describes intent/effect (performLogin, loadData, setRecords), SubView written as @ViewBuilder function, Swift API Design Guideline violations on public APIs, inefficient Effect composition | |
| - 🔵 [P4] Readability: View-suffix naming, Spacer() misuse, missing final / private, guard / ternary / line-break style violations, constant groups declared as struct instead of enum | |
| - ⚪ [P5] Nitpick: typos, whitespace, formatting | |
| Format: "🔴 **[P1] Critical**\\n\\nActual comment content here..." | |
| Ignore comments and formatting-only changes. Write all review comments in Korean using Markdown, without greetings or closings. | |
| Respond ONLY with a JSON object in this exact format: | |
| { | |
| "summary": "전체 리뷰 요약 (한국어, 마크다운)", | |
| "comments": [ | |
| { | |
| "path": "file path relative to repo root (from the b/ prefix in diff)", | |
| "line": <exact N from [LINE N] annotation>, | |
| "code_snippet": "the actual code content from that line", | |
| "body": "🔴/🟠/🟡/🔵/⚪ **[P1~P5] Label**\\n\\n리뷰 코멘트 (한국어, 마크다운. 개선이 필요하면 \\\`\\\`\\\`suggestion 블록 포함)" | |
| } | |
| ] | |
| } | |
| If no issues are found, return {"summary": "...", "comments": []}. | |
| <annotated_diff> | |
| ${annotated_diff} | |
| </annotated_diff>`; | |
| const result = await model.generateContent(prompt); | |
| const text = result.response.text(); | |
| fs.writeFileSync("review_result.json", text); | |
| - name: Post Inline Review Comments | |
| if: steps.get_diff.outputs.skip_review == 'false' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require("fs"); | |
| const raw = fs.readFileSync("review_result.json", "utf8"); | |
| const validLinesMap = JSON.parse(fs.readFileSync("valid_lines.json", "utf8")); | |
| const lineContentMap = JSON.parse(fs.readFileSync("line_content_map.json", "utf8")); | |
| let review; | |
| try { | |
| review = JSON.parse(raw); | |
| } catch (e) { | |
| console.log("JSON parse failed, falling back to single comment"); | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.payload.pull_request.number, | |
| body: raw, | |
| }); | |
| return; | |
| } | |
| function snapToValidLine(comment) { | |
| const fileLines = validLinesMap[comment.path]; | |
| if (!fileLines || fileLines.length === 0) { | |
| console.log(`Dropping comment: file not in diff - ${comment.path}`); | |
| return null; | |
| } | |
| if (fileLines.includes(comment.line)) return comment.line; | |
| const fileContent = lineContentMap[comment.path] || {}; | |
| if (comment.code_snippet) { | |
| const snippet = comment.code_snippet.trim(); | |
| for (const validLine of fileLines) { | |
| const content = fileContent[validLine] || ""; | |
| if (content.includes(snippet) || snippet.includes(content)) { | |
| return validLine; | |
| } | |
| } | |
| } | |
| const THRESHOLD = 5; | |
| let bestLine = null; | |
| let bestDist = THRESHOLD + 1; | |
| for (const validLine of fileLines) { | |
| const dist = Math.abs(validLine - comment.line); | |
| if (dist < bestDist) { | |
| bestDist = dist; | |
| bestLine = validLine; | |
| } | |
| } | |
| if (bestLine !== null) return bestLine; | |
| return null; | |
| } | |
| const reviewComments = (review.comments || []) | |
| .map((c) => { | |
| const snappedLine = snapToValidLine(c); | |
| if (snappedLine === null) return null; | |
| return { | |
| path: c.path, | |
| line: snappedLine, | |
| side: "RIGHT", | |
| body: c.body, | |
| }; | |
| }) | |
| .filter(Boolean); | |
| if (reviewComments.length > 0) { | |
| await github.rest.pulls.createReview({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: context.payload.pull_request.number, | |
| event: "COMMENT", | |
| body: review.summary || "", | |
| comments: reviewComments, | |
| }); | |
| } else if (review.summary) { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.payload.pull_request.number, | |
| body: review.summary, | |
| }); | |
| } |