Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 34 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ GitHub Organization webhook 수신용 엔드포인트

#### `POST /approve-prs`

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

**Request:**

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

#### `POST /merge-prs`

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

**Request:**

Expand Down Expand Up @@ -239,10 +239,41 @@ GitHub Organization webhook 수신용 엔드포인트
}
```

#### `POST /resolve-iteration`

주어진 날짜가 어느 기수의 몇 주차인지 조회합니다. 리트코드 스터디는 기수마다 프로젝트 보드를 새로 만들고 보드의 Week 필드(Iteration)가 주차 일정을 관리하므로, 주차 번호를 하드코딩하지 않고 여기서 읽습니다.

Actions의 기본 `GITHUB_TOKEN`으로는 org 프로젝트 보드를 읽을 수 없어(`read:project` 스코프 부재) App 토큰을 가진 이 워커가 조회를 대신합니다. 완료된 주차는 `completedIterations`로 옮겨가므로 두 배열을 모두 확인합니다.

**Request:**

```json
{ "date": "2026-08-08" }
```

**Response:**

```json
{
"success": true,
"date": "2026-08-08",
"found": true,
"cohort": 8,
"week": 7,
"week_label": "Week 7",
"project_number": 29,
"project_title": "리트코드 스터디 8기",
"start_date": "2026-08-02",
"end_date": "2026-08-08"
}
```

해당 날짜를 포함하는 주차가 없으면(기수 사이 휴식기 등) `{ "success": true, "date": "...", "found": false }`를 돌려줍니다.

### 3. 워크플로우

1. Open PR 목록 조회 (GitHub REST API)
2. `maintenance` 라벨 있는 PR 스킵
2. 풀이 제출 PR이 아닌 PR 스킵 (제목이 `WEEK NN Solutions` 형태가 아니면 봇 테스트·저장소 정리 PR로 간주)
3. 각 PR의 Week 설정 확인 (GitHub GraphQL API - Projects v2 접근 필요)
4. Week 없음 → 경고 댓글 작성 (중복 방지: Bot이 작성한 경고 댓글이 이미 있으면 스킵)
5. Week 있음 → 기존 경고 댓글 삭제 (Bot이 작성한 Week 경고 댓글만)
Expand Down
10 changes: 10 additions & 0 deletions handlers/approve_prs.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
filterTargetPrs,
filterByWeekAndStatus,
getSkipReason,
getCheckSkipReason,
formatResult,
safeJson,
hasApprovedReview,
Expand Down Expand Up @@ -48,6 +49,15 @@ export async function approvePrs(request, env) {
continue;
}

const checkSkipReason = getCheckSkipReason(pr);
if (checkSkipReason) {
skipped++;
results.push(
formatResult(pr, { skipped: true, reason: checkSkipReason })
);
continue;
}

const alreadyApproved = await hasApprovedReview(
repoOwner,
repoName,
Expand Down
9 changes: 4 additions & 5 deletions handlers/check-weeks.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { generateGitHubAppToken, getGitHubHeaders } from "../utils/github.js";
import { corsResponse, errorResponse } from "../utils/cors.js";
import { handleWeekComment } from "../utils/prWeeks.js";
import { validateOrganization, hasMaintenanceLabel } from "../utils/validation.js";
import { validateOrganization, isSolutionPR } from "../utils/validation.js";
import { ALLOWED_REPO } from "../utils/constants.js";

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

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

Expand Down
53 changes: 53 additions & 0 deletions handlers/resolve-iteration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* 특정 날짜가 속한 기수/주차 조회 핸들러
*
* Actions의 기본 GITHUB_TOKEN으로는 org 프로젝트 보드를 읽을 수 없어
* (read:project 스코프 부재), 주차 조회를 App 토큰을 가진 워커가 대신한다.
*/

import { generateGitHubAppToken } from "../utils/github.js";
import { corsResponse, errorResponse } from "../utils/cors.js";
import { resolveIteration } from "../utils/iterations.js";

const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;

/**
* @param {Request} request - Worker request object
* @param {Env} env - Worker bindings (APP_ID, PRIVATE_KEY, etc.)
*/
export async function resolveIterationHandler(request, env) {
try {
const { date } = await request.json().catch(() => ({}));

if (!date) {
return errorResponse("Missing required field: date (YYYY-MM-DD)", 400);
}

if (!DATE_PATTERN.test(date)) {
return errorResponse(`Invalid date format: ${date} (expected YYYY-MM-DD)`, 400);
}

const appToken = await generateGitHubAppToken(env);
const iteration = await resolveIteration(appToken, date);

if (!iteration) {
return corsResponse({ success: true, date, found: false });
}

return corsResponse({
success: true,
date,
found: true,
cohort: iteration.cohort,
week: iteration.week,
week_label: iteration.weekLabel,
project_number: iteration.projectNumber,
project_title: iteration.projectTitle,
start_date: iteration.startDate,
end_date: iteration.endDate,
});
} catch (error) {
console.error("resolveIteration error:", error);
return errorResponse(`Internal server error: ${error.message}`, 500);
}
}
8 changes: 1 addition & 7 deletions handlers/tag-patterns.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
} from "../utils/commentMarker.js";
import { getGitHubHeaders } from "../utils/github.js";
import { createCodeFence } from "../utils/markdown.js";
import { hasMaintenanceLabel } from "../utils/validation.js";
import { generatePatternAnalysis } from "../utils/openai.js";
import {
callComplexityAnalysis,
Expand Down Expand Up @@ -56,13 +55,8 @@ export async function tagPatterns(
return { skipped: "draft" };
}

const labels = (prData.labels || []).map((l) => l.name);
if (hasMaintenanceLabel(labels)) {
console.log(`[tagPatterns] Skipping PR #${prNumber}: maintenance label`);
return { skipped: "maintenance" };
}

// 2-2. PR 변경 파일 목록 조회 + 필터링
// 풀이 파일이 없는 PR은 아래 SOLUTION_PATH_REGEX 필터에서 걸러진다
const filesResponse = await fetch(
`https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}/files?per_page=100`,
{ headers: getGitHubHeaders(appToken) }
Expand Down
31 changes: 10 additions & 21 deletions handlers/webhooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
} from "../utils/prWeeks.js";
import {
validateOrganization,
hasMaintenanceLabel,
isSolutionPR,
isClosedPR,
} from "../utils/validation.js";
import { ALLOWED_REPO } from "../utils/constants.js";
Expand Down Expand Up @@ -131,7 +131,7 @@ async function handleProjectsV2ItemEvent(payload, env) {
return corsResponse({ message: `Ignored: ${repoName}` });
}

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

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

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

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

const appToken = await generateGitHubAppToken(env);
Expand Down Expand Up @@ -641,15 +639,6 @@ async function handleApprovalRequest(repoOwner, repoName, prNumber, githubToken)
};
}

// maintenance 라벨 체크
const labels = prData.labels.map((l) => l.name);
if (hasMaintenanceLabel(labels)) {
return {
success: false,
error: "PR has maintenance label",
};
}

// 이미 승인되었는지 확인
const alreadyApproved = await hasApprovedReview(
repoOwner,
Expand Down
30 changes: 26 additions & 4 deletions handlers/webhooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,12 @@ describe("webhook 저장소 필터링", () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({ state: "open", labels: [], draft: false }),
Promise.resolve({
state: "open",
title: "[testuser] WEEK 01 Solutions",
labels: [],
draft: false,
}),
});
});

Expand All @@ -68,7 +73,12 @@ describe("webhook 저장소 필터링", () => {
action: "opened",
organization: { login: "DaleStudy" },
repository: { name: "daleui", owner: { login: "DaleStudy" } },
pull_request: { number: 1, labels: [], head: { sha: "abc" } },
pull_request: {
number: 1,
title: "[testuser] WEEK 01 Solutions",
labels: [],
head: { sha: "abc" },
},
});

const response = await handleWebhook(request, env);
Expand All @@ -87,6 +97,7 @@ describe("webhook 저장소 필터링", () => {
},
pull_request: {
number: 1,
title: "[testuser] WEEK 01 Solutions",
labels: [],
head: { sha: "abc" },
user: { login: "testuser" },
Expand Down Expand Up @@ -192,7 +203,12 @@ describe("webhook 저장소 필터링", () => {
name: "leetcode-study",
owner: { login: "OtherOrg" },
},
pull_request: { number: 1, labels: [], head: { sha: "abc" } },
pull_request: {
number: 1,
title: "[testuser] WEEK 01 Solutions",
labels: [],
head: { sha: "abc" },
},
});

const response = await handleWebhook(request, env);
Expand All @@ -208,7 +224,12 @@ describe("webhook 저장소 필터링", () => {
name: "leetcode-study",
owner: { login: "DaleStudy" },
},
pull_request: { number: 1, labels: [], head: { sha: "abc" } },
pull_request: {
number: 1,
title: "[testuser] WEEK 01 Solutions",
labels: [],
head: { sha: "abc" },
},
});

const response = await handleWebhook(request, env);
Expand Down Expand Up @@ -246,6 +267,7 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => {
},
pull_request: {
number: 42,
title: "[testuser] WEEK 01 Solutions",
labels: [],
head: { sha: "head-sha" },
user: { login: "testuser" },
Expand Down
6 changes: 6 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { handleWebhook } from "./handlers/webhooks.js";
import { handleInternalDispatch } from "./handlers/internal-dispatch.js";
import { approvePrs } from "./handlers/approve_prs.js";
import { mergePrs } from "./handlers/merge_prs.js";
import { resolveIterationHandler } from "./handlers/resolve-iteration.js";
import { preflightResponse, corsResponse, errorResponse } from "./utils/cors.js";
import { verifyWebhookSignature } from "./utils/webhook.js";

Expand Down Expand Up @@ -75,6 +76,11 @@ export default {
return mergePrs(request, env);
}

// 특정 날짜가 속한 기수/주차 조회
if (url.pathname === "/resolve-iteration") {
return resolveIterationHandler(request, env);
}

// 지원하지 않는 엔드포인트
return corsResponse({ error: "Not found" }, 404);
},
Expand Down
13 changes: 0 additions & 13 deletions tests/tag-patterns.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,19 +160,6 @@ describe("tagPatterns — skip 조건", () => {
expect(globalThis.fetch).not.toHaveBeenCalled();
});

it("maintenance 라벨이 있으면 skip 한다", async () => {
globalThis.fetch = vi.fn();

const result = await tagPatterns(
REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA,
makePrData({ labels: [{ name: "maintenance" }] }),
APP_TOKEN, OPENAI_KEY
);

expect(result).toEqual({ skipped: "maintenance" });
expect(globalThis.fetch).not.toHaveBeenCalled();
});

it("솔루션 파일이 없으면 skip 한다", async () => {
globalThis.fetch = makeFetchMock({
solutionFiles: [
Expand Down
15 changes: 15 additions & 0 deletions tests/worker-runtime.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ describe("Worker runtime smoke test", () => {
await expect(response.json()).resolves.toEqual({ error: "Not found" });
});

it("routes /resolve-iteration and rejects a malformed date before hitting GitHub", async () => {
const response = await fetchWorker("https://example.com/resolve-iteration", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ date: "2026/08/08" }),
});

expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "Invalid date format: 2026/08/08 (expected YYYY-MM-DD)",
});
});

it("exposes WORKER_URL from wrangler config in env", () => {
expect(env.WORKER_URL).toBe("https://github.dalestudy.workers.dev");
});
Expand Down
3 changes: 0 additions & 3 deletions utils/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ export const WEBHOOK_URL = "https://github.dalestudy.com/webhooks";
export const ALLOWED_ORG = "DaleStudy";
export const ALLOWED_REPO = "leetcode-study";

// 라벨
export const MAINTENANCE_LABEL = "maintenance";

// AI Gateway 를 거치는 OpenAI 엔드포인트.
// OpenAI 키는 게이트웨이에 저장돼 있어 요청에 싣지 않는다. 키를 실으면
// 게이트웨이가 저장된 키를 끼워 넣지 않고 그대로 전달한다.
Expand Down
Loading