Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f9d448c
feat: #4 사전 투표창 도입 — Entity · Feature/View · 코디네이터 라우팅 + 공통 컴포넌트 추가
Roy-wonji May 17, 2026
c38b825
feat: #3 홈 카드 (Hero · Hot · Best · Vote · New) 탭 → 사전 투표창 진입 라우팅
Roy-wonji May 17, 2026
e9d2d05
fix: #3 새로운 배틀 카드 .pen 디자인 매칭 + 홈 헤더 bottom stroke 정합성
Roy-wonji May 17, 2026
0dcd42c
chore: #3 탭바 GNB 아이콘 active/inactive 분리 + SVG single-scale 자산 교체
Roy-wonji May 17, 2026
52c5b10
chore: Service 모듈에 Entity 의존성 추가
Roy-wonji May 17, 2026
d3a3c5b
feat: #31 Apple 로그인 — identityToken 전달 + redirectUri 옵셔널 + UserDefaul…
Roy-wonji May 18, 2026
5e8bec8
chore: #31 Apple 로그인 동작 확인을 위해 Splash → Auth 강제 진입
Roy-wonji May 18, 2026
294f91a
ci: Gemini 2.5 Flash 기반 인라인 PR 코드 리뷰 워크플로우 추가
Roy-wonji May 18, 2026
338cce2
feat: #19 Kingfisher 글로벌 requestModifier — 보호 이미지(/api/v1/resources/.…
Roy-wonji May 18, 2026
e52b894
feat: #3 홈 카드/섹션 .pen 정밀 매칭 + Vote 카드 Property 1=Default ↔ Result 인터랙션
Roy-wonji May 18, 2026
0cd82b9
chore: SwiftUI sub-view `@ViewBuilder` 명시 일괄 적용 + AGENTS.md 규칙 추가
Roy-wonji May 18, 2026
101edb6
revert: #31 Splash → Auth 강제 진입 임시 변경 되돌림
Roy-wonji May 18, 2026
75752cf
refactor: 홈 Hero/Battle 카드 레이아웃 정리 및 미사용 파라미터 정리
Roy-wonji May 18, 2026
a3cd05e
chore: Tokens Studio 3-파일 토큰 포맷 적용 (디자인 v0519)
Roy-wonji May 19, 2026
1ad7f09
feat: #4 Poll 도메인 풀체인 + 사전 투표창 API 연동 + Skeleton Loader
Roy-wonji May 19, 2026
a60f267
feat: #18 공용 SkeletonView + KFImage 로딩 placeholder 적용
Roy-wonji May 19, 2026
016a1cb
chore: #24 DesignSystem 토큰 코드젠 정리 + LoginView 누락 색 토큰 alias 적용
Roy-wonji May 19, 2026
d1cc3ec
fix: #19 Kingfisher Bearer 토큰을 picke 보호 리소스(/api/) 에만 제한
Roy-wonji May 19, 2026
f53ed2a
refactor: PR #34 코드리뷰 피드백 반영 — 단일 진실 공급원 / 중복 제거
Roy-wonji May 19, 2026
a80ac9d
fix: #4 fetchPoll 응답을 state.battle 로 매핑 — mock 데이터 노출 방지
Roy-wonji May 19, 2026
9bf1ade
chore: KingfisherConfigurator 미사용 Foundations import 제거
Roy-wonji May 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
270 changes: 270 additions & 0 deletions .github/workflows/gemini-code-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
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
Comment on lines +41 to +42
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fetch fork PR heads from the PR ref

For pull requests opened from forks, origin points at the base repository, so fetching and diffing origin/${{ github.event.pull_request.head.ref }} only works when a branch with the same name exists in the base repo. In fork PRs this step fails before Gemini runs (or can diff the wrong branch if the name collides); fetch the PR ref/head SHA instead of the head branch name from origin.

Useful? React with 👍 / 👎.

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,
});
}
84 changes: 84 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,55 @@ public var body: some View {
- 한 메서드 안에서 다시 큰 블록이 생기면 더 작게 쪼개기 (재귀 적용)
- 공통 컴포넌트는 별도 파일 (`Components/*.swift`) 로 추출

#### 🧱 `@ViewBuilder` 함수 vs `var` — 자식 개수 / 분기 유무로 결정

분리한 sub-view 의 선언 형태는 **자식 개수와 분기 유무** 로만 정한다.

```swift
// ✅ 다중 자식을 감싸거나 if/else · switch 분기가 있으면 `@ViewBuilder` + 함수
@ViewBuilder
private func hotBattlesSection() -> some View {
VStack(alignment: .leading, spacing: 12) {
HomeSectionHeader(title: "지금 뜨는 배틀") { send(.seeMoreTapped(.hotBattles)) }
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(store.hotBattles) { HotBattleCardView(battle: $0) }
}
}
}
}

@ViewBuilder
private func thumbnail(url: URL?) -> some View {
if let url {
KFImage(url).resizable().scaledToFill()
} else {
Color.neutral200
}
}

// ✅ 단일 뷰만 반환하면 `private var` 형태
private var primaryButton: some View {
CustomButton(
action: { send(.primaryButtonTapped) },
title: "사전 투표하기",
config: CustomButtonConfig.primary(.large, height: 52),
isEnable: store.isPrimaryButtonEnabled
)
}

// ❌ 금지 — VStack 으로 자식 여러 개 감싸는데 var 만 쓰는 경우 (분기 / 동적 children 추가 시 깨짐)
private var section: some View {
VStack { ... } // → @ViewBuilder + func 으로 가야 안전
}
```

규칙:
- **`@ViewBuilder` + `private func`** : VStack/HStack/ZStack 등으로 **자식 ≥ 2개** 를 감싸거나 `if` / `switch` / `ForEach` 같은 분기·반복이 있을 때
- **`private var ...: some View`** : **단일 뷰** 1개만 반환할 때 (단순 wrapping · CTA 버튼 · 단일 Image 등)
- body 안에서 호출하는 sub-view 가 인자가 필요하면 함수, 없으면 var 가 우선 — 기준은 "자식 수 / 분기 유무" 가 먼저
- 레퍼런스: `HomeView.hotBattlesSection`, `PreVoteView.primaryButton`, `HeroCardView.thumbnail`

#### 🔤 폰트 — `.font(.system(...))` 금지, Pretendard 토큰 사용

```swift
Expand Down Expand Up @@ -414,6 +463,41 @@ public init(
- `MoyaProviderPool` 은 더 이상 RepositoryImpl 에서 직접 호출하지 않는다 (필요 시 풀 자체에서 내부적으로 캐시 처리)
- 레퍼런스: `HomeRepositoryImpl`, `AuthRepositoryImpl`, AsyncMoya `MoyaProvider+Factory.default`, `Extension+MoyaProvider+Auth.authorized`

#### 🧭 Coordinator — `extension X { @Reducer public enum XScreen { ... } }` 구조 절대 건드리지 말 것

TCAFlow 기반 Coordinator 의 `XScreen` 정의는 반드시 **별도 extension 의 `@Reducer public enum`** 형태를 유지한다. 마이그레이션 / 리팩터링 / 자동 포맷터 어떤 이유로도 이 구조를 본체 안으로 끌어들이거나 `enum`을 `struct`/`Reducer` 로 바꾸지 말 것.

```swift
// ✅ 올바른 패턴 — extension + @Reducer + public enum + State: Equatable 보조 extension
@FlowCoordinator(screen: "HomeScreen", navigation: true)
public struct HomeCoordinator {
// … State / Action / handleRoute …
}

extension HomeCoordinator {
@Reducer
public enum HomeScreen {
case home(HomeFeature)
case preVote(PreVoteFeature)
}
}

extension HomeCoordinator.HomeScreen.State: Equatable {}

// ❌ 금지 — Coordinator 본체 안에 enum 을 인라인 선언
public struct HomeCoordinator {
@Reducer
public enum HomeScreen { ... } // 안 됨 (매크로 인식 / Route 추론 깨짐)
}
```

규칙:
- `XScreen` 은 `@Reducer public enum`. struct 로 바꾸지 말 것
- 본체와 분리된 **별도 extension** 안에 선언
- `extension Coordinator.XScreen.State: Equatable {}` 보조 conformance 도 같이 유지 (Route diff 비교 필요)
- 라우터 핸들러 (`routerAction`) 안에서 `state.routes.push/pop/goBack` 직접 호출은 OK, 단 `dismiss`/`submit` 같이 반복되는 종료 액션은 `.send(.view(.backAction))` 으로 일원화
- 레퍼런스: `HomeCoordinator`, `AuthCoordinator`, `MainTabCoordinator`

### 📏 Swift 코딩 규칙 (`docs/agent/swift-coding-rules.md`)
- Swift 스타일 가이드
- 에러 처리 패턴
Expand Down
9 changes: 5 additions & 4 deletions Projects/App/Project.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import ProjectDescription
import DependencyPackagePlugin
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [P4] Readability

import DependencyPackagePlugin 선언은 프로젝트 상단에 이미 존재하며 중복됩니다. 제거해 주세요.

Suggested change
import DependencyPackagePlugin

import DependencyPlugin
import ProjectDescription
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [P4] Readability

import ProjectDescription 선언은 프로젝트 상단에 이미 존재하며 중복됩니다. 제거해 주세요.

Suggested change
import ProjectDescription

import ProjectTemplatePlugin
import DependencyPackagePlugin

let project = Project.makeAppModule(
name: Project.Environment.appName,
Expand All @@ -16,8 +16,9 @@ let project = Project.makeAppModule(
.SPM.googleMobileAds,
.SPM.firebaseCrashlytics,
.SPM.mixpanel,
.SPM.mixpanelSessionReplay

.SPM.mixpanelSessionReplay,
.SPM.kingfisher,

],
sources: ["Sources/**"],
resources: ["Resources/**"],
Expand Down
Loading
Loading