Skip to content
Open
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
63 changes: 57 additions & 6 deletions .github/workflows/automated-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]

# 기본은 읽기 전용. secrets가 필요한 publish 권한은 ai-review job에서만 올린다.
permissions:
contents: read
issues: write
pull-requests: write

jobs:
review:
# 1) 검증 단계: PR 브랜치 코드를 실제로 실행(lint/typecheck/test/build)한다.
# untrusted PR 코드가 도는 곳이므로 어떤 secret도 주입하지 않는다.
# 브랜치 보호에서 필수로 걸 체크는 이 job(checks)이다.
checks:
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-latest
steps:
Expand All @@ -25,7 +27,56 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile

- name: Run automated review
- name: Run checks (lint/typecheck/test/build)
env:
REVIEW_REPORT_TITLE: "자동 PR 코드 리뷰"
REVIEW_CADENCE: "PR 리뷰"
REVIEW_BASE: ${{ github.event.pull_request.base.sha }}
REVIEW_HEAD: ${{ github.event.pull_request.head.sha }}
# typecheck(tsc)는 next build가 만드는 next-env.d.ts와 .next/types에 의존한다.
# fresh 체크아웃에서 typecheck가 먼저 돌면 Logo.png 타입 등을 못 찾으므로 build를 먼저 돌린다.
REVIEW_CHECKS: "lint,build,typecheck,test"
# NEXT_PUBLIC_* 는 원래 브라우저에 노출되는 공개 값이다.
# 빌드(프리렌더)에서 supabase 클라이언트 생성이 throw하지 않도록 플레이스홀더를 준다.
NEXT_PUBLIC_SUPABASE_URL: ${{ vars.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co' }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ vars.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'placeholder-anon-key' }}
run: bun run review -- --skip-agents --fail-on P1

- name: Upload check report
if: always()
uses: actions/upload-artifact@v4
with:
name: automated-code-review-checks
path: tools/code-review/output/

# 2) LLM 리뷰 + Notion/GitHub publish: secrets가 필요하다.
# checks(untrusted 코드 실행)와 분리하고, environment 승인 게이트 뒤에 둔다.
# --skip-checks 로 코드 실행 없이 diff 수집·리뷰·publish만 수행한다.
ai-review:
if: ${{ !github.event.pull_request.draft }}
needs: checks
runs-on: ubuntu-latest
# Settings > Environments 에서 이 환경에 required reviewers 를 추가하면
# secrets가 실린 이 job이 수동 승인 후에만 실행된다.
environment: ai-review
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Bun
uses: oven-sh/setup-bun@v2

# 설치 단계에는 secrets를 노출하지 않는다(postinstall 스크립트 방어).
- name: Install dependencies
run: bun install --frozen-lockfile

- name: Run AI review & publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
Expand All @@ -40,11 +91,11 @@ jobs:
NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}
NOTION_TITLE_PROPERTY: ${{ vars.NOTION_TITLE_PROPERTY || 'Name' }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: bun run review -- --fail-on P1
run: bun run review -- --skip-checks

- name: Upload review report
if: always()
uses: actions/upload-artifact@v4
with:
name: automated-code-review
name: automated-code-review-ai
path: tools/code-review/output/
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"start": "bun ext start",
"lint": "bun biome check --apply .",
"review": "bun tools/code-review/review-harness.ts",
"test": "bun test ./src/**/*.test.ts",
"test": "TZ=Asia/Seoul bun test src",
"format": "biome format --write",
"prepare": "husky"
},
Expand Down
57 changes: 57 additions & 0 deletions src/app/(with-sidebar)/mypage/components/Heatmap/hook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { createAndFillHeatmap, getColor } from "./hook";

// bun test는 타임존을 UTC로 강제한다.
// toISOString() 기반의 말일 누락 버그는 KST에서만 재현되므로 KST로 고정한다.
beforeAll(() => {
process.env.TZ = "Asia/Seoul";
});

describe("테스트 환경", () => {
test("KST(UTC+9)로 실행된다", () => {
// getTimezoneOffset은 KST에서 -540분이다. TZ 고정이 적용됐는지 확인한다.
expect(new Date().getTimezoneOffset()).toBe(-540);
});
});

describe("createAndFillHeatmap", () => {
const today = new Date(2026, 6, 15); // 2026-07-15 (KST)

test("이번 달 말일(2026-07-31)이 포함된다", () => {
const result = createAndFillHeatmap({}, today);
expect("2026-07-31" in result).toBe(true);
expect(result["2026-07-31"]).toBe(0);
});

test("12개월치(2025-08-01 ~ 2026-07-31, 365일)를 생성한다", () => {
const result = createAndFillHeatmap({}, today);
expect(Object.keys(result).length).toBe(365);
expect("2025-08-01" in result).toBe(true);
});

test("풀이가 있는 날은 개수, 없는 날은 0으로 채운다", () => {
const result = createAndFillHeatmap({ "2026-07-10": 3 }, today);
expect(result["2026-07-10"]).toBe(3);
expect(result["2026-07-11"]).toBe(0);
});
});

describe("getColor 경계값", () => {
test("0은 가장 옅은 단계", () => {
expect(getColor(0)).toBe("#F2F4F6");
});

test("1과 2는 같은 단계", () => {
expect(getColor(1)).toBe("#D4E4FF");
expect(getColor(2)).toBe("#D4E4FF");
});

test("3은 그다음 단계", () => {
expect(getColor(3)).toBe("#689FFF");
});

test("4 이상은 가장 진한 단계", () => {
expect(getColor(4)).toBe("#1B64FA");
expect(getColor(10)).toBe("#1B64FA");
});
});
12 changes: 9 additions & 3 deletions src/app/(with-sidebar)/mypage/components/Heatmap/hook.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
export function createAndFillHeatmap(mySolves: Record<string, number>) {
import { format } from "date-fns";

export function createAndFillHeatmap(
mySolves: Record<string, number>,
today = new Date(),
) {
const yearSolves: Record<string, number> = {}; //Record 가 무엇인지 알아보기
const today = new Date();
const start = new Date(today.getFullYear(), today.getMonth() - 11, 1);
const end = new Date(today.getFullYear(), today.getMonth() + 1, 0);

const cur = new Date(start);
while (cur <= end) {
const key = cur.toISOString().slice(0, 10);
// toISOString()은 UTC로 변환돼 KST에서 하루가 밀리고 말일이 누락된다.
// DB의 date(YYYY-MM-DD)와 맞추려면 로컬 기준으로 포맷해야 한다.
const key = format(cur, "yyyy-MM-dd");
yearSolves[key] = mySolves[key] ?? 0;
cur.setDate(cur.getDate() + 1);
}
Expand Down
40 changes: 40 additions & 0 deletions src/app/(with-sidebar)/mypage/components/LineChart/hook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { createSixMonthObject } from "./hook";

// bun test는 타임존을 UTC로 강제한다.
// toISOString()/new Date(key)의 월 밀림 버그는 KST에서만 재현되므로 KST로 고정한다.
beforeAll(() => {
process.env.TZ = "Asia/Seoul";
});

describe("테스트 환경", () => {
test("KST(UTC+9)로 실행된다", () => {
expect(new Date().getTimezoneOffset()).toBe(-540);
});
});

describe("createSixMonthObject", () => {
const today = new Date(2026, 6, 15); // 2026-07-15 (KST)

test("정확히 6개월치를 만든다", () => {
const result = createSixMonthObject([], today);
expect(result.length).toBe(6);
});

test("첫 항목은 5개월 전(2월), 마지막 항목은 기준월(7월)이다", () => {
const result = createSixMonthObject([], today);
expect(result[0].date).toBe("2월");
expect(result[5].date).toBe("7월");
});

test("월 라벨과 값이 정확히 매핑되고 데이터 없는 달은 0이다", () => {
const lineData = [
{ date: "2026-07", value: 5 },
{ date: "2026-05", value: 2 },
];
const result = createSixMonthObject(lineData, today);
expect(result.find((r) => r.date === "7월")?.value).toBe(5);
expect(result.find((r) => r.date === "5월")?.value).toBe(2);
expect(result.find((r) => r.date === "6월")?.value).toBe(0);
});
});
38 changes: 22 additions & 16 deletions src/app/(with-sidebar)/mypage/components/LineChart/hook.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,32 @@
import { format } from "date-fns";

interface lineDataType {
date: string;
value: number;
}

export function createSixMonthObject(lineData: lineDataType[] | undefined) {
export function createSixMonthObject(
lineData: lineDataType[] | undefined,
today = new Date(),
) {
const result: lineDataType[] = [];
const today = new Date();
const startMonth = new Date(today.getFullYear(), today.getMonth() - 4);
const endMonth = new Date(today.getFullYear(), today.getMonth() + 1);
const curMonth = new Date(startMonth);

while (curMonth <= endMonth) {
const key = curMonth.toISOString().slice(0, 7);
const lineValue = lineData?.find((item) => item.date === key)?.value ?? 0;
const newKey = new Date(key);
const changeKey =
newKey.getFullYear() === today.getFullYear()
? `${newKey.getMonth() + 1}월`
: `${newKey.getFullYear()}년 ${newKey.getMonth() + 1}월`;
result.push({ date: changeKey, value: lineValue });
// 기준월(0)에서 5개월 전까지, 마지막 항목이 기준월이 되도록 정확히 6개를 만든다.
const startMonth = new Date(today.getFullYear(), today.getMonth() - 5, 1);

curMonth.setMonth(curMonth.getMonth() + 1);
for (let i = 0; i < 6; i++) {
const cur = new Date(
startMonth.getFullYear(),
startMonth.getMonth() + i,
1,
);
// toISOString()/new Date(key)의 UTC 변환을 피하려고 cur을 그대로 쓰고 로컬 기준으로 포맷한다.
const key = format(cur, "yyyy-MM");
const value = lineData?.find((item) => item.date === key)?.value ?? 0;
const label =
cur.getFullYear() === today.getFullYear()
? `${cur.getMonth() + 1}월`
: `${cur.getFullYear()}년 ${cur.getMonth() + 1}월`;
result.push({ date: label, value });
}

return result;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { ChevronDown, ChevronUp } from "lucide-react";
import { useState } from "react";
import { useForm, useFormContext, useWatch } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form";
import { ALGORITHM_TAGS } from "@/constants/problem";

function TagSection() {
const [isOpen, setIsOpen] = useState(false);
const { setValue, control, watch } = useFormContext();
const { setValue, control } = useFormContext();

// const { watch } = useForm({ defaultValues: { tags: [] } });
const selectedTags = useWatch({ control, name: "tags" });
const selectedTags: string[] = useWatch({ control, name: "tags" }) ?? [];
// const selectedTags = watch("tags") ?? [];

const toggleTag = (tag: string) => {
Expand Down
26 changes: 13 additions & 13 deletions src/app/(with-sidebar)/problems/page.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { FileCode2, Plus } from "lucide-react";
import { useState } from "react";
import SelectDemo from "@/components/SelectCustom";
import { getMyMemberInfo } from "@/lib/api/members";
import { getProblems } from "@/lib/api/problems";
import ProblemCard from "./components/ProblemCard";
import type { Problem, UserProfile } from "@/lib/types/study";
import Input from "@/components/ui/Input";
import { Skeleton } from "@/components/ui/skeleton";
import {
ALGORITHM_TAGS,
DIFFICULT_TAGS,
PLATFORM_TAGS,
type PlatformType,
} from "@/constants/problem";
import { useState } from "react";
import Input from "@/components/ui/Input";
import { Skeleton } from "@/components/ui/skeleton";
import { FileCode2, Plus } from "lucide-react";
import { getMyMemberInfo } from "@/lib/api/members";
import { getProblems } from "@/lib/api/problems";
import { getStudyMembers } from "@/lib/api/study";
import ProblemModal from "./components/ProblemModal";
import type { Problem } from "@/lib/types/study";
import ProblemAddModal from "./components/ProblemAddModal";
import ProblemCard from "./components/ProblemCard";
import ProblemModal from "./components/ProblemModal";

function page() {
// const [platform, setPlatform] = useState("all");
Expand Down Expand Up @@ -87,7 +87,7 @@ function page() {
placeholder: "난이도",
},
{
array: members,
array: members ?? [],
value: memberName,
change: setMemberName,
placeholder: "풀이자",
Expand Down Expand Up @@ -163,9 +163,9 @@ function page() {
</div>
<div className="flex flex-col gap-3 overflow-y-auto">
{isProblemLoading ? (
new Array(3)
.fill(0)
.map((_, i) => <Skeleton key={`${i}`} className="h-18 w-full" />)
["s1", "s2", "s3"].map((skeletonKey) => (
<Skeleton key={skeletonKey} className="h-18 w-full" />
))
) : problems?.length === 0 ? (
<div className="flex flex-col items-center justify-center mt-20 text-center">
<FileCode2 size={40} className="text-gray-200 mb-3" />
Expand Down
Loading
Loading