diff --git a/AGENTS.md b/AGENTS.md index 5be9fc3..0b6ad98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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:** @@ -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:** @@ -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 경고 댓글만) diff --git a/handlers/approve_prs.js b/handlers/approve_prs.js index c48fb74..5bb0cd9 100644 --- a/handlers/approve_prs.js +++ b/handlers/approve_prs.js @@ -6,6 +6,7 @@ import { filterTargetPrs, filterByWeekAndStatus, getSkipReason, + getCheckSkipReason, formatResult, safeJson, hasApprovedReview, @@ -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, diff --git a/handlers/check-weeks.js b/handlers/check-weeks.js index 3f9b6ab..0916983 100644 --- a/handlers/check-weeks.js +++ b/handlers/check-weeks.js @@ -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"; /** @@ -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; } diff --git a/handlers/resolve-iteration.js b/handlers/resolve-iteration.js new file mode 100644 index 0000000..b1548c3 --- /dev/null +++ b/handlers/resolve-iteration.js @@ -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); + } +} diff --git a/handlers/tag-patterns.js b/handlers/tag-patterns.js index dde7e73..91f244e 100644 --- a/handlers/tag-patterns.js +++ b/handlers/tag-patterns.js @@ -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, @@ -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) } diff --git a/handlers/webhooks.js b/handlers/webhooks.js index 153cb2a..68dd52d 100644 --- a/handlers/webhooks.js +++ b/handlers/webhooks.js @@ -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"; @@ -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) } @@ -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" }); } } @@ -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); @@ -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, diff --git a/handlers/webhooks.test.js b/handlers/webhooks.test.js index f862b14..1634572 100644 --- a/handlers/webhooks.test.js +++ b/handlers/webhooks.test.js @@ -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, + }), }); }); @@ -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); @@ -87,6 +97,7 @@ describe("webhook 저장소 필터링", () => { }, pull_request: { number: 1, + title: "[testuser] WEEK 01 Solutions", labels: [], head: { sha: "abc" }, user: { login: "testuser" }, @@ -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); @@ -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); @@ -246,6 +267,7 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { }, pull_request: { number: 42, + title: "[testuser] WEEK 01 Solutions", labels: [], head: { sha: "head-sha" }, user: { login: "testuser" }, diff --git a/index.js b/index.js index 2fa0b52..ce4ad56 100644 --- a/index.js +++ b/index.js @@ -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"; @@ -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); }, diff --git a/tests/tag-patterns.test.js b/tests/tag-patterns.test.js index 1c8bd37..21b6344 100644 --- a/tests/tag-patterns.test.js +++ b/tests/tag-patterns.test.js @@ -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: [ diff --git a/tests/worker-runtime.test.js b/tests/worker-runtime.test.js index 3dcb81d..f323249 100644 --- a/tests/worker-runtime.test.js +++ b/tests/worker-runtime.test.js @@ -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"); }); diff --git a/utils/constants.js b/utils/constants.js index aca9752..8b7f4ee 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -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 키는 게이트웨이에 저장돼 있어 요청에 싣지 않는다. 키를 실으면 // 게이트웨이가 저장된 키를 끼워 넣지 않고 그대로 전달한다. diff --git a/utils/iterations.js b/utils/iterations.js new file mode 100644 index 0000000..30e215e --- /dev/null +++ b/utils/iterations.js @@ -0,0 +1,171 @@ +/** + * 프로젝트 보드 Iteration 기반 기수/주차 해석 + * + * 리트코드 스터디는 기수마다 프로젝트 보드를 새로 만들고, 보드의 Week 필드 + * (Iteration)가 주차 일정을 관리한다. 주차 번호를 어디에도 하드코딩하지 않고 + * 보드에서 읽으므로, 기수가 바뀌어도 별도 설정 없이 다음 기수로 넘어간다. + */ + +import { getGitHubHeaders } from "./github.js"; +import { ALLOWED_ORG } from "./constants.js"; + +const COHORT_TITLE_PATTERN = /^리트코드 스터디 (\d+)기$/; +const WEEK_TITLE_PATTERN = /^Week (\d+)$/; +const WEEK_FIELD_NAME = "Week"; + +/** + * 날짜 문자열을 일 단위로 이동 + * + * @param {string} date - YYYY-MM-DD + * @param {number} days + * @returns {string} YYYY-MM-DD + */ +export function shiftDate(date, days) { + const shifted = new Date(`${date}T00:00:00Z`); + shifted.setUTCDate(shifted.getUTCDate() + days); + return shifted.toISOString().slice(0, 10); +} + +/** + * 보드 제목에서 기수 번호 추출 ("리트코드 스터디 8기" → 8) + * + * @returns {number|null} 리트코드 스터디 보드가 아니면 null + */ +export function parseCohort(title) { + const matched = COHORT_TITLE_PATTERN.exec(title ?? ""); + return matched ? Number(matched[1]) : null; +} + +/** + * Iteration 제목에서 주차 번호 추출 ("Week 7" → 7) + * + * @returns {number|null} + */ +export function parseWeek(title) { + const matched = WEEK_TITLE_PATTERN.exec(title ?? ""); + return matched ? Number(matched[1]) : null; +} + +/** + * 주어진 날짜를 포함하는 iteration 탐색 + * + * @param {Array} projects - { number, title, cohort, iterations } 목록 + * @param {string} date - YYYY-MM-DD + * @returns {Object|null} 해당 날짜가 속한 주차 정보 + */ +export function findCoveringIteration(projects, date) { + for (const project of projects) { + for (const iteration of project.iterations) { + const week = parseWeek(iteration.title); + if (week === null) { + continue; + } + + const endDate = shiftDate(iteration.startDate, iteration.duration - 1); + if (date < iteration.startDate || date > endDate) { + continue; + } + + return { + cohort: project.cohort, + week, + weekLabel: iteration.title, + projectNumber: project.number, + projectTitle: project.title, + startDate: iteration.startDate, + endDate, + }; + } + } + + return null; +} + +/** + * 리트코드 스터디 보드들의 Week iteration 목록 조회 + * + * 완료된 주차는 completedIterations로 옮겨가므로 두 배열을 모두 합친다. + * + * @param {string} appToken + * @returns {Promise} { number, title, cohort, iterations } 목록 + */ +export async function fetchCohortProjects(appToken) { + const query = ` + query($org: String!) { + organization(login: $org) { + projectsV2(first: 20, orderBy: { field: NUMBER, direction: DESC }) { + nodes { + number + title + fields(first: 50) { + nodes { + ... on ProjectV2IterationField { + name + configuration { + iterations { title startDate duration } + completedIterations { title startDate duration } + } + } + } + } + } + } + } + } + `; + + const response = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + ...getGitHubHeaders(appToken), + "Content-Type": "application/json", + }, + body: JSON.stringify({ query, variables: { org: ALLOWED_ORG } }), + }); + + const result = await response.json(); + + if (result.errors) { + throw new Error(`GraphQL error: ${JSON.stringify(result.errors)}`); + } + + const nodes = result.data?.organization?.projectsV2?.nodes ?? []; + + return nodes + .map((project) => { + const cohort = parseCohort(project.title); + if (cohort === null) { + return null; + } + + const weekField = (project.fields?.nodes ?? []).find( + (field) => field?.name === WEEK_FIELD_NAME + ); + if (!weekField) { + return null; + } + + return { + number: project.number, + title: project.title, + cohort, + iterations: [ + ...(weekField.configuration?.iterations ?? []), + ...(weekField.configuration?.completedIterations ?? []), + ], + }; + }) + .filter(Boolean); +} + +/** + * 특정 날짜가 속한 기수/주차 해석 + * + * @param {string} appToken + * @param {string} date - YYYY-MM-DD + * @returns {Promise} + */ +export async function resolveIteration(appToken, date) { + const projects = await fetchCohortProjects(appToken); + return findCoveringIteration(projects, date); +} diff --git a/utils/iterations.test.js b/utils/iterations.test.js new file mode 100644 index 0000000..0e8850b --- /dev/null +++ b/utils/iterations.test.js @@ -0,0 +1,216 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { + shiftDate, + parseCohort, + parseWeek, + findCoveringIteration, + fetchCohortProjects, +} from "./iterations.js"; + +const APP_TOKEN = "fake-app-token"; + +function weekField(iterations, completedIterations) { + return { + name: "Week", + configuration: { iterations, completedIterations }, + }; +} + +function iteration(title, startDate) { + return { title, startDate, duration: 7 }; +} + +describe("shiftDate", () => { + it("일 단위로 날짜를 이동한다", () => { + expect(shiftDate("2026-08-02", 6)).toBe("2026-08-08"); + expect(shiftDate("2026-08-09", -1)).toBe("2026-08-08"); + }); + + it("월·연 경계를 넘는다", () => { + expect(shiftDate("2026-08-30", 6)).toBe("2026-09-05"); + expect(shiftDate("2026-01-01", -1)).toBe("2025-12-31"); + }); +}); + +describe("parseCohort", () => { + it("리트코드 스터디 보드에서 기수를 뽑는다", () => { + expect(parseCohort("리트코드 스터디 8기")).toBe(8); + expect(parseCohort("리트코드 스터디 11기")).toBe(11); + }); + + it("다른 보드는 null을 돌려준다", () => { + expect(parseCohort("리트코드 스터디 템플릿")).toBeNull(); + expect(parseCohort("AI 스터디")).toBeNull(); + expect(parseCohort("AI 프로젝트 1기")).toBeNull(); + expect(parseCohort(undefined)).toBeNull(); + }); +}); + +describe("parseWeek", () => { + it("Week 제목에서 주차를 뽑는다", () => { + expect(parseWeek("Week 1")).toBe(1); + expect(parseWeek("Week 15")).toBe(15); + }); + + it("형식이 다르면 null을 돌려준다", () => { + expect(parseWeek("Week 7(current)")).toBeNull(); + expect(parseWeek("Sprint 3")).toBeNull(); + expect(parseWeek(null)).toBeNull(); + }); +}); + +describe("findCoveringIteration", () => { + const projects = [ + { + number: 29, + title: "리트코드 스터디 8기", + cohort: 8, + iterations: [iteration("Week 7", "2026-08-02"), iteration("Week 6", "2026-07-26")], + }, + ]; + + it("주차 마지막 날(토요일)을 그 주차로 판정한다", () => { + const found = findCoveringIteration(projects, "2026-08-08"); + + expect(found).toMatchObject({ + cohort: 8, + week: 7, + weekLabel: "Week 7", + startDate: "2026-08-02", + endDate: "2026-08-08", + }); + }); + + it("주차 첫 날(일요일)도 그 주차로 판정한다", () => { + expect(findCoveringIteration(projects, "2026-08-02")).toMatchObject({ week: 7 }); + }); + + it("직전 주차는 직전 iteration으로 판정한다", () => { + expect(findCoveringIteration(projects, "2026-07-26")).toMatchObject({ week: 6 }); + }); + + it("어느 주차에도 속하지 않으면 null을 돌려준다", () => { + expect(findCoveringIteration(projects, "2026-08-09")).toBeNull(); + expect(findCoveringIteration(projects, "2026-07-25")).toBeNull(); + }); + + it("기수가 바뀌어도 날짜로 기수와 주차를 함께 판정한다", () => { + const acrossCohorts = [ + { + number: 30, + title: "리트코드 스터디 9기", + cohort: 9, + iterations: [iteration("Week 1", "2026-10-18")], + }, + { + number: 29, + title: "리트코드 스터디 8기", + cohort: 8, + iterations: [iteration("Week 15", "2026-09-27")], + }, + ]; + + expect(findCoveringIteration(acrossCohorts, "2026-10-03")).toMatchObject({ + cohort: 8, + week: 15, + }); + expect(findCoveringIteration(acrossCohorts, "2026-10-24")).toMatchObject({ + cohort: 9, + week: 1, + }); + // 기수 사이 휴식기 + expect(findCoveringIteration(acrossCohorts, "2026-10-10")).toBeNull(); + }); + + it("Week 형식이 아닌 iteration은 무시한다", () => { + const odd = [ + { + number: 29, + title: "리트코드 스터디 8기", + cohort: 8, + iterations: [iteration("오리엔테이션", "2026-08-02")], + }, + ]; + + expect(findCoveringIteration(odd, "2026-08-05")).toBeNull(); + }); +}); + +describe("fetchCohortProjects", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function mockProjects(nodes) { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ data: { organization: { projectsV2: { nodes } } } }), + }); + } + + it("완료된 주차와 남은 주차를 하나로 합친다", async () => { + mockProjects([ + { + number: 29, + title: "리트코드 스터디 8기", + fields: { + nodes: [ + weekField( + [iteration("Week 8", "2026-08-09")], + [iteration("Week 7", "2026-08-02")] + ), + ], + }, + }, + ]); + + const projects = await fetchCohortProjects(APP_TOKEN); + + expect(projects).toHaveLength(1); + expect(projects[0]).toMatchObject({ number: 29, cohort: 8 }); + expect(projects[0].iterations.map((it) => it.title)).toEqual([ + "Week 8", + "Week 7", + ]); + }); + + it("리트코드 스터디 보드가 아니거나 Week 필드가 없으면 제외한다", async () => { + mockProjects([ + { + number: 35, + title: "AI 스터디", + fields: { nodes: [weekField([iteration("Week 1", "2026-08-02")], [])] }, + }, + { + number: 30, + title: "리트코드 스터디 9기", + fields: { nodes: [{ name: "Status" }] }, + }, + { + number: 15, + title: "리트코드 스터디 템플릿", + fields: { nodes: [weekField([], [iteration("Week 1", "2024-08-11")])] }, + }, + { + number: 29, + title: "리트코드 스터디 8기", + fields: { nodes: [weekField([], [iteration("Week 7", "2026-08-02")])] }, + }, + ]); + + const projects = await fetchCohortProjects(APP_TOKEN); + + expect(projects.map((project) => project.number)).toEqual([29]); + }); + + it("GraphQL 오류는 예외로 올린다", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ errors: [{ message: "Bad credentials" }] }), + }); + + await expect(fetchCohortProjects(APP_TOKEN)).rejects.toThrow("GraphQL error"); + }); +}); diff --git a/utils/prActions.js b/utils/prActions.js index 3ddbaf1..50f1df5 100644 --- a/utils/prActions.js +++ b/utils/prActions.js @@ -4,8 +4,11 @@ import { errorResponse } from "./cors.js"; import { getGitHubHeaders } from "./github.js"; -import { validateOrganization, hasMaintenanceLabel } from "./validation.js"; -import { getProjectFields } from "./prWeeks.js"; +import { validateOrganization } from "./validation.js"; +import { getPrFields } from "./prWeeks.js"; + +// statusCheckRollup이 전부 통과했을 때의 상태 +const PASSING_CHECK_STATE = "SUCCESS"; /** * 공통 payload 파서 @@ -105,11 +108,6 @@ export function filterTargetPrs(pullRequests, excludes) { * 공통 skip 조건 */ export function getSkipReason(pr) { - const labels = (pr.labels || []).map((label) => label.name); - if (hasMaintenanceLabel(labels)) { - return "maintenance labeled"; - } - if (pr.draft) { return "draft PR"; } @@ -117,6 +115,22 @@ export function getSkipReason(pr) { return null; } +/** + * CI 미통과 skip 사유 + * + * statusCheckRollup은 PENDING/FAILURE/ERROR/EXPECTED를 돌려주고, 체크가 하나도 + * 없으면 null이다. SUCCESS가 아니면 승인 리뷰를 남기지 않는다. + * + * @returns {string|null} 통과했으면 null + */ +export function getCheckSkipReason(pr) { + if (pr.checkState === PASSING_CHECK_STATE) { + return null; + } + + return `checks ${(pr.checkState ?? "missing").toLowerCase()}`; +} + /** * 결과 포매터 */ @@ -204,12 +218,13 @@ export async function filterByWeekAndStatus(pullRequests, weekFilter, repoOwner, let solvingExcluded = 0; for (const pr of pullRequests) { - // 프로젝트 필드 조회 (Week, Status) - const fields = await getProjectFields(repoOwner, repoName, pr.number, appToken); + // 프로젝트 필드(Week, Status)와 CI 상태 조회 + const fields = await getPrFields(repoOwner, repoName, pr.number, appToken); - // PR 객체에 Week와 Status 메타데이터 추가 + // PR 객체에 메타데이터 추가 pr.week = fields.week; pr.status = fields.status; + pr.checkState = fields.checkState; // Week 필터링 if (!matchesWeek(fields.week, weekFilter)) { diff --git a/utils/prActions.test.js b/utils/prActions.test.js new file mode 100644 index 0000000..df84379 --- /dev/null +++ b/utils/prActions.test.js @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; + +import { getCheckSkipReason } from "./prActions.js"; + +describe("getCheckSkipReason", () => { + it("모든 체크가 통과하면 승인을 막지 않는다", () => { + expect(getCheckSkipReason({ checkState: "SUCCESS" })).toBeNull(); + }); + + it("체크가 실패하거나 아직 끝나지 않았으면 사유를 돌려준다", () => { + expect(getCheckSkipReason({ checkState: "FAILURE" })).toBe("checks failure"); + expect(getCheckSkipReason({ checkState: "PENDING" })).toBe("checks pending"); + expect(getCheckSkipReason({ checkState: "ERROR" })).toBe("checks error"); + }); + + it("체크가 하나도 없으면 승인하지 않는다", () => { + expect(getCheckSkipReason({ checkState: null })).toBe("checks missing"); + expect(getCheckSkipReason({})).toBe("checks missing"); + }); +}); diff --git a/utils/prWeeks.js b/utils/prWeeks.js index f29d255..99de3b1 100644 --- a/utils/prWeeks.js +++ b/utils/prWeeks.js @@ -5,10 +5,13 @@ import { generateGitHubAppToken, getGitHubHeaders } from "./github.js"; /** - * PR의 프로젝트 필드 값 조회 (Week, Status 등) - * @returns {Promise<{week: string|null, status: string|null}>} + * PR의 프로젝트 필드 값(Week, Status)과 CI 롤업 상태 조회 + * + * 자동 승인·병합이 세 값을 모두 보므로 한 번의 GraphQL 호출로 묶는다. + * + * @returns {Promise<{week: string|null, status: string|null, checkState: string|null}>} */ -export async function getProjectFields(repoOwner, repoName, prNumber, appToken) { +export async function getPrFields(repoOwner, repoName, prNumber, appToken) { const query = ` query { repository(owner: "${repoOwner}", name: "${repoName}") { @@ -38,6 +41,15 @@ export async function getProjectFields(repoOwner, repoName, prNumber, appToken) } } } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + } + } + } + } } } } @@ -50,10 +62,15 @@ export async function getProjectFields(repoOwner, repoName, prNumber, appToken) }); const data = await response.json(); - const projectItems = - data.data?.repository?.pullRequest?.projectItems?.nodes || []; + const pullRequest = data.data?.repository?.pullRequest; + const projectItems = pullRequest?.projectItems?.nodes || []; - const result = { week: null, status: null }; + const result = { + week: null, + status: null, + checkState: + pullRequest?.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state ?? null, + }; for (const item of projectItems) { const fieldValues = item.fieldValues?.nodes || []; @@ -78,15 +95,6 @@ export async function getProjectFields(repoOwner, repoName, prNumber, appToken) return result; } -/** - * PR의 Week 값 조회 (GraphQL) - * @deprecated Use getProjectFields() for better performance - */ -export async function getWeekValue(repoOwner, repoName, prNumber, appToken) { - const fields = await getProjectFields(repoOwner, repoName, prNumber, appToken); - return fields.week; -} - /** * 경고 댓글 내용 */ @@ -200,7 +208,7 @@ export async function removeWarningComment(repoOwner, repoName, prNumber, env) { * @returns {Promise} Week 값 또는 null */ export async function handleWeekComment(repoOwner, repoName, prNumber, env, appToken) { - const fields = await getProjectFields(repoOwner, repoName, prNumber, appToken); + const fields = await getPrFields(repoOwner, repoName, prNumber, appToken); const weekValue = fields.week; if (!weekValue) { diff --git a/utils/validation.js b/utils/validation.js index 6380c71..fe9cb07 100644 --- a/utils/validation.js +++ b/utils/validation.js @@ -2,7 +2,11 @@ * 검증 로직 유틸리티 */ -import { ALLOWED_ORG, MAINTENANCE_LABEL } from "./constants.js"; +import { ALLOWED_ORG } from "./constants.js"; + +// "[dale] WEEK 07 Solutions" 같은 풀이 제출 PR 제목. +// 제목 규칙이 강제되지 않아 "Solutions"를 빼먹는 PR이 있으므로 주차만 확인한다. +const SOLUTION_TITLE_PATTERN = /week\s*\d+/i; /** * Organization 검증 @@ -12,10 +16,14 @@ export function validateOrganization(orgName) { } /** - * maintenance 라벨이 있는 PR인지 확인 + * 풀이 제출 PR인지 제목으로 판별 + * + * 봇 테스트나 저장소 정리 PR을 Week 경고 댓글 대상에서 제외한다. 프로젝트 + * 연결 여부는 참가자가 PR을 올린 뒤 직접 설정해서 갓 올라온 PR일수록 비어 + * 있으므로 판별에 쓸 수 없다. */ -export function hasMaintenanceLabel(labels) { - return labels.includes(MAINTENANCE_LABEL); +export function isSolutionPR(title) { + return SOLUTION_TITLE_PATTERN.test(title ?? ""); } /** diff --git a/utils/validation.test.js b/utils/validation.test.js new file mode 100644 index 0000000..4d442af --- /dev/null +++ b/utils/validation.test.js @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; + +import { isSolutionPR } from "./validation.js"; + +describe("isSolutionPR", () => { + it("풀이 제출 PR 제목을 인식한다", () => { + expect(isSolutionPR("[dale] WEEK 07 Solutions")).toBe(true); + expect(isSolutionPR("[freemjstudio] WEEK 08 Solutions")).toBe(true); + expect(isSolutionPR("[user] Week 1 solutions")).toBe(true); + expect(isSolutionPR("[user] WEEK15 Solutions")).toBe(true); + }); + + it("봇 테스트나 저장소 정리 PR은 제외한다", () => { + expect(isSolutionPR("260806 봇 댓글 기능 테스트")).toBe(false); + expect(isSolutionPR("bot test")).toBe(false); + expect(isSolutionPR("fix: 머지 방식을 squash로 바꾸고")).toBe(false); + expect(isSolutionPR("README.md에 스터디 4기 정보 추가")).toBe(false); + }); + + it("Solutions를 빼먹은 제목도 풀이 PR로 인식한다", () => { + expect(isSolutionPR("[chapse57] Week 3")).toBe(true); + }); + + it("제목이 없으면 false를 돌려준다", () => { + expect(isSolutionPR(undefined)).toBe(false); + expect(isSolutionPR(null)).toBe(false); + expect(isSolutionPR("")).toBe(false); + }); +});