Skip to content

Commit f1bfb73

Browse files
authored
Merge pull request #52 from DaleStudy/feat/wrapup-automation
리트코드 주차 마무리 자동화
2 parents ddb38ea + 03120a7 commit f1bfb73

18 files changed

Lines changed: 656 additions & 86 deletions

‎AGENTS.md‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ GitHub Organization webhook 수신용 엔드포인트
180180

181181
#### `POST /approve-prs`
182182

183-
열려있는 답안 제출 PR을 일괄 승인합니다. `excludes` 배열로 특정 PR을 제외합니다. 이미 승인된 PR, `maintenance` 라벨, Draft 상태의 PR은 자동으로 스킵됩니다.
183+
열려있는 답안 제출 PR을 일괄 승인합니다. `excludes` 배열로 특정 PR을 제외합니다. 이미 승인된 PR과 Draft 상태의 PR은 자동으로 스킵됩니다. CI가 통과하지 않은 PR(`statusCheckRollup.state !== "SUCCESS"`)도 스킵되어, 체크가 깨진 PR에는 승인 리뷰를 남기지 않습니다.
184184

185185
**Request:**
186186

@@ -208,7 +208,7 @@ GitHub Organization webhook 수신용 엔드포인트
208208

209209
#### `POST /merge-prs`
210210

211-
열려있는 PR을 일괄 병합합니다. 기본 병합 방식은 `squash`이며 `merge_method` 값으로 `merge | squash | rebase` 중 선택할 수 있습니다. `excludes`로 특정 PR을 제외할 수 있습니다. 승인 리뷰가 없거나 `maintenance` 라벨이 붙은 PR, Draft PR, GitHub `mergeable_state !== "clean"` PR은 스킵되며 `unknown`/`behind` 상태는 최대 1초 후 한 번 더 확인합니다.
211+
열려있는 PR을 일괄 병합합니다. 기본 병합 방식은 `squash`이며 `merge_method` 값으로 `merge | squash | rebase` 중 선택할 수 있습니다. `excludes`로 특정 PR을 제외할 수 있습니다. 승인 리뷰가 없는 PR, Draft PR, GitHub `mergeable_state !== "clean"` PR은 스킵되며 `unknown`/`behind` 상태는 최대 1초 후 한 번 더 확인합니다.
212212

213213
**Request:**
214214

@@ -239,10 +239,41 @@ GitHub Organization webhook 수신용 엔드포인트
239239
}
240240
```
241241

242+
#### `POST /resolve-iteration`
243+
244+
주어진 날짜가 어느 기수의 몇 주차인지 조회합니다. 리트코드 스터디는 기수마다 프로젝트 보드를 새로 만들고 보드의 Week 필드(Iteration)가 주차 일정을 관리하므로, 주차 번호를 하드코딩하지 않고 여기서 읽습니다.
245+
246+
Actions의 기본 `GITHUB_TOKEN`으로는 org 프로젝트 보드를 읽을 수 없어(`read:project` 스코프 부재) App 토큰을 가진 이 워커가 조회를 대신합니다. 완료된 주차는 `completedIterations`로 옮겨가므로 두 배열을 모두 확인합니다.
247+
248+
**Request:**
249+
250+
```json
251+
{ "date": "2026-08-08" }
252+
```
253+
254+
**Response:**
255+
256+
```json
257+
{
258+
"success": true,
259+
"date": "2026-08-08",
260+
"found": true,
261+
"cohort": 8,
262+
"week": 7,
263+
"week_label": "Week 7",
264+
"project_number": 29,
265+
"project_title": "리트코드 스터디 8기",
266+
"start_date": "2026-08-02",
267+
"end_date": "2026-08-08"
268+
}
269+
```
270+
271+
해당 날짜를 포함하는 주차가 없으면(기수 사이 휴식기 등) `{ "success": true, "date": "...", "found": false }`를 돌려줍니다.
272+
242273
### 3. 워크플로우
243274

244275
1. Open PR 목록 조회 (GitHub REST API)
245-
2. `maintenance` 라벨 있는 PR 스킵
276+
2. 풀이 제출 PR이 아닌 PR 스킵 (제목이 `WEEK NN Solutions` 형태가 아니면 봇 테스트·저장소 정리 PR로 간주)
246277
3. 각 PR의 Week 설정 확인 (GitHub GraphQL API - Projects v2 접근 필요)
247278
4. Week 없음 → 경고 댓글 작성 (중복 방지: Bot이 작성한 경고 댓글이 이미 있으면 스킵)
248279
5. Week 있음 → 기존 경고 댓글 삭제 (Bot이 작성한 Week 경고 댓글만)

‎handlers/approve_prs.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
filterTargetPrs,
77
filterByWeekAndStatus,
88
getSkipReason,
9+
getCheckSkipReason,
910
formatResult,
1011
safeJson,
1112
hasApprovedReview,
@@ -48,6 +49,15 @@ export async function approvePrs(request, env) {
4849
continue;
4950
}
5051

52+
const checkSkipReason = getCheckSkipReason(pr);
53+
if (checkSkipReason) {
54+
skipped++;
55+
results.push(
56+
formatResult(pr, { skipped: true, reason: checkSkipReason })
57+
);
58+
continue;
59+
}
60+
5161
const alreadyApproved = await hasApprovedReview(
5262
repoOwner,
5363
repoName,

‎handlers/check-weeks.js‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import { generateGitHubAppToken, getGitHubHeaders } from "../utils/github.js";
66
import { corsResponse, errorResponse } from "../utils/cors.js";
77
import { handleWeekComment } from "../utils/prWeeks.js";
8-
import { validateOrganization, hasMaintenanceLabel } from "../utils/validation.js";
8+
import { validateOrganization, isSolutionPR } from "../utils/validation.js";
99
import { ALLOWED_REPO } from "../utils/constants.js";
1010

1111
/**
@@ -55,11 +55,10 @@ export async function checkWeeks(request, env) {
5555
// 각 PR 검사
5656
for (const pr of prs) {
5757
const prNumber = pr.number;
58-
const labels = pr.labels.map((l) => l.name);
5958

60-
// maintenance 라벨이 있으면 스킵
61-
if (hasMaintenanceLabel(labels)) {
62-
console.log(`Skipping PR #${prNumber}: has maintenance label`);
59+
// 풀이 제출 PR이 아니면 스킵
60+
if (!isSolutionPR(pr.title)) {
61+
console.log(`Skipping PR #${prNumber}: not a solution PR`);
6362
continue;
6463
}
6564

‎handlers/resolve-iteration.js‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* 특정 날짜가 속한 기수/주차 조회 핸들러
3+
*
4+
* Actions의 기본 GITHUB_TOKEN으로는 org 프로젝트 보드를 읽을 수 없어
5+
* (read:project 스코프 부재), 주차 조회를 App 토큰을 가진 워커가 대신한다.
6+
*/
7+
8+
import { generateGitHubAppToken } from "../utils/github.js";
9+
import { corsResponse, errorResponse } from "../utils/cors.js";
10+
import { resolveIteration } from "../utils/iterations.js";
11+
12+
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
13+
14+
/**
15+
* @param {Request} request - Worker request object
16+
* @param {Env} env - Worker bindings (APP_ID, PRIVATE_KEY, etc.)
17+
*/
18+
export async function resolveIterationHandler(request, env) {
19+
try {
20+
const { date } = await request.json().catch(() => ({}));
21+
22+
if (!date) {
23+
return errorResponse("Missing required field: date (YYYY-MM-DD)", 400);
24+
}
25+
26+
if (!DATE_PATTERN.test(date)) {
27+
return errorResponse(`Invalid date format: ${date} (expected YYYY-MM-DD)`, 400);
28+
}
29+
30+
const appToken = await generateGitHubAppToken(env);
31+
const iteration = await resolveIteration(appToken, date);
32+
33+
if (!iteration) {
34+
return corsResponse({ success: true, date, found: false });
35+
}
36+
37+
return corsResponse({
38+
success: true,
39+
date,
40+
found: true,
41+
cohort: iteration.cohort,
42+
week: iteration.week,
43+
week_label: iteration.weekLabel,
44+
project_number: iteration.projectNumber,
45+
project_title: iteration.projectTitle,
46+
start_date: iteration.startDate,
47+
end_date: iteration.endDate,
48+
});
49+
} catch (error) {
50+
console.error("resolveIteration error:", error);
51+
return errorResponse(`Internal server error: ${error.message}`, 500);
52+
}
53+
}

‎handlers/tag-patterns.js‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
} from "../utils/commentMarker.js";
1818
import { getGitHubHeaders } from "../utils/github.js";
1919
import { createCodeFence } from "../utils/markdown.js";
20-
import { hasMaintenanceLabel } from "../utils/validation.js";
2120
import { generatePatternAnalysis } from "../utils/openai.js";
2221
import {
2322
callComplexityAnalysis,
@@ -56,13 +55,8 @@ export async function tagPatterns(
5655
return { skipped: "draft" };
5756
}
5857

59-
const labels = (prData.labels || []).map((l) => l.name);
60-
if (hasMaintenanceLabel(labels)) {
61-
console.log(`[tagPatterns] Skipping PR #${prNumber}: maintenance label`);
62-
return { skipped: "maintenance" };
63-
}
64-
6558
// 2-2. PR 변경 파일 목록 조회 + 필터링
59+
// 풀이 파일이 없는 PR은 아래 SOLUTION_PATH_REGEX 필터에서 걸러진다
6660
const filesResponse = await fetch(
6761
`https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/files?per_page=100`,
6862
{ headers: getGitHubHeaders(appToken) }

‎handlers/webhooks.js‎

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
} from "../utils/prWeeks.js";
1616
import {
1717
validateOrganization,
18-
hasMaintenanceLabel,
18+
isSolutionPR,
1919
isClosedPR,
2020
} from "../utils/validation.js";
2121
import { ALLOWED_REPO } from "../utils/constants.js";
@@ -131,7 +131,7 @@ async function handleProjectsV2ItemEvent(payload, env) {
131131
return corsResponse({ message: `Ignored: ${repoName}` });
132132
}
133133

134-
// PR 상태 확인 (closed PR, maintenance 라벨 예외)
134+
// PR 상태 확인 (closed PR, 풀이 PR이 아닌 경우 예외)
135135
const prResponse = await fetch(
136136
`https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}`,
137137
{ headers: getGitHubHeaders(appToken) }
@@ -146,11 +146,10 @@ async function handleProjectsV2ItemEvent(payload, env) {
146146
return corsResponse({ message: "Ignored: closed PR" });
147147
}
148148

149-
// maintenance 라벨 체크
150-
const labels = prData.labels.map((l) => l.name);
151-
if (hasMaintenanceLabel(labels)) {
152-
console.log(`Skipping PR #${prNumber}: has maintenance label`);
153-
return corsResponse({ message: "Ignored: maintenance label" });
149+
// 풀이 제출 PR이 아니면 스킵
150+
if (!isSolutionPR(prData.title)) {
151+
console.log(`Skipping PR #${prNumber}: not a solution PR`);
152+
return corsResponse({ message: "Ignored: not a solution PR" });
154153
}
155154
}
156155

@@ -231,11 +230,10 @@ async function handlePullRequestEvent(payload, env, ctx) {
231230
const repoName = payload.repository.name;
232231
const prNumber = pr.number;
233232

234-
// maintenance 라벨 체크 (early exit - GitHub API 호출 전에)
235-
const labels = pr.labels.map((l) => l.name);
236-
if (hasMaintenanceLabel(labels)) {
237-
console.log(`Skipping PR #${prNumber}: has maintenance label`);
238-
return corsResponse({ message: "Ignored: maintenance label" });
233+
// 풀이 제출 PR이 아니면 스킵 (early exit - GitHub API 호출 전에)
234+
if (!isSolutionPR(pr.title)) {
235+
console.log(`Skipping PR #${prNumber}: not a solution PR`);
236+
return corsResponse({ message: "Ignored: not a solution PR" });
239237
}
240238

241239
const appToken = await generateGitHubAppToken(env);
@@ -641,15 +639,6 @@ async function handleApprovalRequest(repoOwner, repoName, prNumber, githubToken)
641639
};
642640
}
643641

644-
// maintenance 라벨 체크
645-
const labels = prData.labels.map((l) => l.name);
646-
if (hasMaintenanceLabel(labels)) {
647-
return {
648-
success: false,
649-
error: "PR has maintenance label",
650-
};
651-
}
652-
653642
// 이미 승인되었는지 확인
654643
const alreadyApproved = await hasApprovedReview(
655644
repoOwner,

‎handlers/webhooks.test.js‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,12 @@ describe("webhook 저장소 필터링", () => {
5858
globalThis.fetch = vi.fn().mockResolvedValue({
5959
ok: true,
6060
json: () =>
61-
Promise.resolve({ state: "open", labels: [], draft: false }),
61+
Promise.resolve({
62+
state: "open",
63+
title: "[testuser] WEEK 01 Solutions",
64+
labels: [],
65+
draft: false,
66+
}),
6267
});
6368
});
6469

@@ -68,7 +73,12 @@ describe("webhook 저장소 필터링", () => {
6873
action: "opened",
6974
organization: { login: "DaleStudy" },
7075
repository: { name: "daleui", owner: { login: "DaleStudy" } },
71-
pull_request: { number: 1, labels: [], head: { sha: "abc" } },
76+
pull_request: {
77+
number: 1,
78+
title: "[testuser] WEEK 01 Solutions",
79+
labels: [],
80+
head: { sha: "abc" },
81+
},
7282
});
7383

7484
const response = await handleWebhook(request, env);
@@ -87,6 +97,7 @@ describe("webhook 저장소 필터링", () => {
8797
},
8898
pull_request: {
8999
number: 1,
100+
title: "[testuser] WEEK 01 Solutions",
90101
labels: [],
91102
head: { sha: "abc" },
92103
user: { login: "testuser" },
@@ -192,7 +203,12 @@ describe("webhook 저장소 필터링", () => {
192203
name: "leetcode-study",
193204
owner: { login: "OtherOrg" },
194205
},
195-
pull_request: { number: 1, labels: [], head: { sha: "abc" } },
206+
pull_request: {
207+
number: 1,
208+
title: "[testuser] WEEK 01 Solutions",
209+
labels: [],
210+
head: { sha: "abc" },
211+
},
196212
});
197213

198214
const response = await handleWebhook(request, env);
@@ -208,7 +224,12 @@ describe("webhook 저장소 필터링", () => {
208224
name: "leetcode-study",
209225
owner: { login: "DaleStudy" },
210226
},
211-
pull_request: { number: 1, labels: [], head: { sha: "abc" } },
227+
pull_request: {
228+
number: 1,
229+
title: "[testuser] WEEK 01 Solutions",
230+
labels: [],
231+
head: { sha: "abc" },
232+
},
212233
});
213234

214235
const response = await handleWebhook(request, env);
@@ -246,6 +267,7 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => {
246267
},
247268
pull_request: {
248269
number: 42,
270+
title: "[testuser] WEEK 01 Solutions",
249271
labels: [],
250272
head: { sha: "head-sha" },
251273
user: { login: "testuser" },

‎index.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { handleWebhook } from "./handlers/webhooks.js";
99
import { handleInternalDispatch } from "./handlers/internal-dispatch.js";
1010
import { approvePrs } from "./handlers/approve_prs.js";
1111
import { mergePrs } from "./handlers/merge_prs.js";
12+
import { resolveIterationHandler } from "./handlers/resolve-iteration.js";
1213
import { preflightResponse, corsResponse, errorResponse } from "./utils/cors.js";
1314
import { verifyWebhookSignature } from "./utils/webhook.js";
1415

@@ -75,6 +76,11 @@ export default {
7576
return mergePrs(request, env);
7677
}
7778

79+
// 특정 날짜가 속한 기수/주차 조회
80+
if (url.pathname === "/resolve-iteration") {
81+
return resolveIterationHandler(request, env);
82+
}
83+
7884
// 지원하지 않는 엔드포인트
7985
return corsResponse({ error: "Not found" }, 404);
8086
},

‎tests/tag-patterns.test.js‎

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -160,19 +160,6 @@ describe("tagPatterns — skip 조건", () => {
160160
expect(globalThis.fetch).not.toHaveBeenCalled();
161161
});
162162

163-
it("maintenance 라벨이 있으면 skip 한다", async () => {
164-
globalThis.fetch = vi.fn();
165-
166-
const result = await tagPatterns(
167-
REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA,
168-
makePrData({ labels: [{ name: "maintenance" }] }),
169-
APP_TOKEN, OPENAI_KEY
170-
);
171-
172-
expect(result).toEqual({ skipped: "maintenance" });
173-
expect(globalThis.fetch).not.toHaveBeenCalled();
174-
});
175-
176163
it("솔루션 파일이 없으면 skip 한다", async () => {
177164
globalThis.fetch = makeFetchMock({
178165
solutionFiles: [

‎tests/worker-runtime.test.js‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@ describe("Worker runtime smoke test", () => {
4141
await expect(response.json()).resolves.toEqual({ error: "Not found" });
4242
});
4343

44+
it("routes /resolve-iteration and rejects a malformed date before hitting GitHub", async () => {
45+
const response = await fetchWorker("https://example.com/resolve-iteration", {
46+
method: "POST",
47+
headers: {
48+
"Content-Type": "application/json",
49+
},
50+
body: JSON.stringify({ date: "2026/08/08" }),
51+
});
52+
53+
expect(response.status).toBe(400);
54+
await expect(response.json()).resolves.toEqual({
55+
error: "Invalid date format: 2026/08/08 (expected YYYY-MM-DD)",
56+
});
57+
});
58+
4459
it("exposes WORKER_URL from wrangler config in env", () => {
4560
expect(env.WORKER_URL).toBe("https://github.dalestudy.workers.dev");
4661
});

0 commit comments

Comments
 (0)