-
Notifications
You must be signed in to change notification settings - Fork 0
280 lines (265 loc) · 15 KB
/
Copy pathgemini-code-review.yml
File metadata and controls
280 lines (265 loc) · 15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
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 TRIPLE = "```";
const promptLines = [
"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 (이 프로젝트는 AGENTS.md 규칙을 따른다 — SubView 분리는 @ViewBuilder private func 또는 private var 형태가 정식이며 위반이 아니다. SubView 를 struct 로 강제하는 의견은 절대 내지 말 것); 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 " + TRIPLE + "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 등급은 사용하지 않는다. 빈 줄·공백·trailing comma·주석 줄 같은 포맷 의견은 절대 만들지 말 것.)",
"",
"Format: \"🔴 **[P1] Critical**\\n\\nActual comment content here...\"",
"",
"Ignore the following entirely — these are NOT review issues for this project:",
"- blank lines, whitespace, trailing commas, comment-only lines (P5 nitpicks)",
"- SubView 를 struct 로 분리하라는 의견 (이 프로젝트는 AGENTS.md 의 @ViewBuilder 패턴이 정식)",
"- import 순서, 줄 단위 들여쓰기",
"- description 같은 BaseTargetType 의 정식 프로퍼티 네이밍 의견",
"",
"Only report substantive issues — bugs, architecture violations, security/concurrency risks. Do NOT generate P5 nitpicks. 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~P4] Label**\\n\\n리뷰 코멘트 (한국어, 마크다운. 개선이 필요하면 " + TRIPLE + "suggestion 블록 포함)\"",
" }",
" ]",
"}",
"If no issues are found, return {\"summary\": \"...\", \"comments\": []}.",
"",
"<annotated_diff>",
annotated_diff,
"</annotated_diff>"
];
const prompt = promptLines.join("\n");
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,
});
}