Skip to content

Commit 9d28048

Browse files
robinjoonclaude
andauthored
feat: Goal CRUD API 구현 (#11)
* feat: Goal CRUD API 구현 (#10) 목표 생성/조회/수정/삭제 GraphQL API를 DDD + Clean Architecture 기반으로 구현한다. 인증된 사용자만 접근 가능하며, 수정/삭제는 본인 목표에 대해서만 허용된다. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: GoalTitle 글자수 제한 제거 및 미완료 plan 정리 GoalTitle VO에서 200자 길이 제한을 제거하고 blank 검증만 유지한다. graphql-schema-description plan의 미완료 항목을 완료 처리한다. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: PR/push 시 미완료 plan.md 차단 훅 추가 gh pr create 또는 git push 실행 전 docs/plan/**/plan.md에 미완료 항목이 있으면 차단하여, 불완전한 계획이 머지되는 것을 방지한다. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 78dc984 commit 9d28048

24 files changed

Lines changed: 903 additions & 1 deletion

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Claude Code 훅: PR 생성 / push 시 미완료 plan.md 차단
4+
5+
gh pr create 또는 git push 명령 실행 전,
6+
docs/plan/**/plan.md에 미완료 항목(- [ ])이 있으면 차단합니다.
7+
8+
미완료 계획이 머지되는 것을 방지하여,
9+
다른 작업의 work_plan_enforcer / plan_update_reminder 오작동을 예방합니다.
10+
11+
종료 코드:
12+
0 = 허용
13+
2 = 차단
14+
"""
15+
import json
16+
import os
17+
import re
18+
import sys
19+
20+
PROJECT_DIR = os.environ.get("CLAUDE_PROJECT_DIR", "")
21+
PLAN_BASE = os.path.join(PROJECT_DIR, "docs", "plan") if PROJECT_DIR else ""
22+
23+
REQUIRED_PLAN_FILES = ["plan.md", "context.md", "checklist.md"]
24+
25+
# gh pr create 또는 git push 감지
26+
PR_CREATE_RE = re.compile(r"\bgh\s+pr\s+create\b")
27+
GIT_PUSH_RE = re.compile(r"\bgit\s+push\b")
28+
29+
30+
def find_incomplete_plans():
31+
"""미완료 항목이 있는 plan.md 경로 목록 반환."""
32+
if not PLAN_BASE or not os.path.isdir(PLAN_BASE):
33+
return []
34+
35+
try:
36+
entries = os.listdir(PLAN_BASE)
37+
except OSError:
38+
return []
39+
40+
incomplete = []
41+
for entry in entries:
42+
plan_dir = os.path.join(PLAN_BASE, entry)
43+
if not os.path.isdir(plan_dir):
44+
continue
45+
46+
if not all(
47+
os.path.isfile(os.path.join(plan_dir, f)) for f in REQUIRED_PLAN_FILES
48+
):
49+
continue
50+
51+
plan_md = os.path.join(plan_dir, "plan.md")
52+
try:
53+
with open(plan_md) as f:
54+
content = f.read()
55+
except OSError:
56+
continue
57+
58+
if "- [ ]" in content:
59+
incomplete.append(plan_md)
60+
61+
return incomplete
62+
63+
64+
def main():
65+
try:
66+
data = json.load(sys.stdin)
67+
except json.JSONDecodeError:
68+
sys.exit(0)
69+
70+
tool_name = data.get("tool_name", "")
71+
tool_input = data.get("tool_input", {})
72+
73+
if tool_name != "Bash":
74+
sys.exit(0)
75+
76+
command = tool_input.get("command", "")
77+
78+
if not PR_CREATE_RE.search(command) and not GIT_PUSH_RE.search(command):
79+
sys.exit(0)
80+
81+
incomplete = find_incomplete_plans()
82+
if not incomplete:
83+
sys.exit(0)
84+
85+
plans = "\n".join(f" - {p}" for p in incomplete)
86+
print(
87+
f"[plan-guard] 미완료 plan.md가 있어 PR 생성/push를 차단합니다.\n"
88+
f"다음 plan.md의 모든 항목을 완료([x])하거나 불필요한 계획을 정리하세요:\n"
89+
f"{plans}",
90+
file=sys.stderr,
91+
)
92+
sys.exit(2)
93+
94+
95+
if __name__ == "__main__":
96+
main()
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#!/usr/bin/env python3
2+
"""plan_completion_guard.py 단위 테스트."""
3+
import json
4+
import os
5+
import subprocess
6+
import sys
7+
import tempfile
8+
9+
SCRIPT = os.path.join(os.path.dirname(__file__), "plan_completion_guard.py")
10+
11+
12+
def run_hook(tool_name, tool_input, plan_base=None):
13+
"""훅 스크립트를 실행하고 (exit_code, stderr)를 반환."""
14+
data = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
15+
env = os.environ.copy()
16+
if plan_base is not None:
17+
env["CLAUDE_PROJECT_DIR"] = plan_base
18+
result = subprocess.run(
19+
[sys.executable, SCRIPT],
20+
input=data,
21+
capture_output=True,
22+
text=True,
23+
env=env,
24+
)
25+
return result.returncode, result.stderr
26+
27+
28+
def create_plan_dir(base, name, complete=True):
29+
"""테스트용 plan 디렉토리 생성."""
30+
plan_dir = os.path.join(base, "docs", "plan", name)
31+
os.makedirs(plan_dir, exist_ok=True)
32+
check = "[x]" if complete else "[ ]"
33+
with open(os.path.join(plan_dir, "plan.md"), "w") as f:
34+
f.write(f"# Test\n- {check} step 1\n")
35+
with open(os.path.join(plan_dir, "context.md"), "w") as f:
36+
f.write("# Context\n")
37+
with open(os.path.join(plan_dir, "checklist.md"), "w") as f:
38+
f.write("# Checklist\n")
39+
40+
41+
def test_non_bash_allowed():
42+
code, _ = run_hook("Edit", {"file_path": "src/main/Test.kt"})
43+
assert code == 0, f"Non-Bash should be allowed, got {code}"
44+
45+
46+
def test_non_pr_push_allowed():
47+
code, _ = run_hook("Bash", {"command": "git status"})
48+
assert code == 0, f"Non PR/push should be allowed, got {code}"
49+
50+
51+
def test_pr_create_blocked_with_incomplete_plan():
52+
with tempfile.TemporaryDirectory() as tmp:
53+
create_plan_dir(tmp, "test-task", complete=False)
54+
code, stderr = run_hook(
55+
"Bash", {"command": 'gh pr create --title "test"'}, plan_base=tmp
56+
)
57+
assert code == 2, f"Should block, got {code}"
58+
assert "plan-guard" in stderr
59+
60+
61+
def test_git_push_blocked_with_incomplete_plan():
62+
with tempfile.TemporaryDirectory() as tmp:
63+
create_plan_dir(tmp, "test-task", complete=False)
64+
code, stderr = run_hook(
65+
"Bash", {"command": "git push -u origin main"}, plan_base=tmp
66+
)
67+
assert code == 2, f"Should block, got {code}"
68+
assert "plan-guard" in stderr
69+
70+
71+
def test_pr_create_allowed_with_complete_plan():
72+
with tempfile.TemporaryDirectory() as tmp:
73+
create_plan_dir(tmp, "test-task", complete=True)
74+
code, _ = run_hook(
75+
"Bash", {"command": 'gh pr create --title "test"'}, plan_base=tmp
76+
)
77+
assert code == 0, f"Should allow, got {code}"
78+
79+
80+
def test_pr_create_allowed_with_no_plans():
81+
with tempfile.TemporaryDirectory() as tmp:
82+
os.makedirs(os.path.join(tmp, "docs", "plan"), exist_ok=True)
83+
code, _ = run_hook(
84+
"Bash", {"command": 'gh pr create --title "test"'}, plan_base=tmp
85+
)
86+
assert code == 0, f"Should allow with no plans, got {code}"
87+
88+
89+
def test_mixed_plans_blocked():
90+
with tempfile.TemporaryDirectory() as tmp:
91+
create_plan_dir(tmp, "done-task", complete=True)
92+
create_plan_dir(tmp, "wip-task", complete=False)
93+
code, stderr = run_hook(
94+
"Bash", {"command": 'gh pr create --title "test"'}, plan_base=tmp
95+
)
96+
assert code == 2, f"Should block with any incomplete, got {code}"
97+
assert "wip-task" in stderr
98+
99+
100+
if __name__ == "__main__":
101+
tests = [
102+
test_non_bash_allowed,
103+
test_non_pr_push_allowed,
104+
test_pr_create_blocked_with_incomplete_plan,
105+
test_git_push_blocked_with_incomplete_plan,
106+
test_pr_create_allowed_with_complete_plan,
107+
test_pr_create_allowed_with_no_plans,
108+
test_mixed_plans_blocked,
109+
]
110+
failed = 0
111+
for test in tests:
112+
try:
113+
test()
114+
print(f" PASS: {test.__name__}")
115+
except AssertionError as e:
116+
print(f" FAIL: {test.__name__}: {e}")
117+
failed += 1
118+
print(f"\n{len(tests) - failed}/{len(tests)} passed")
119+
sys.exit(1 if failed else 0)

.claude/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
{
1717
"type": "command",
1818
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/work_plan_enforcer.py\""
19+
},
20+
{
21+
"type": "command",
22+
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/plan_completion_guard.py\""
1923
}
2024
]
2125
},

docs/plan/goal-crud/checklist.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Goal CRUD API 검증 체크리스트
2+
3+
## 필수 항목
4+
- [x] 아키텍처 원칙 준수 (docs/architecture.md 기준)
5+
- [x] 레이어 의존성 규칙 위반 없음
6+
- [x] 테스트 코드 작성 완료 (Domain, Application 필수)
7+
- [x] 모든 테스트 통과
8+
- [x] 기존 테스트 깨지지 않음
9+
10+
## 선택 항목 (해당 시)
11+
- [x] Flyway 마이그레이션 작성
12+
- [ ] API 엔드포인트 동작 확인

docs/plan/goal-crud/context.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Goal CRUD API 맥락
2+
3+
## 배경
4+
PRD 2순위 기능인 "목표 관리" 구현. 피그마 디자인 기준으로 목표 CRUD API 제공.
5+
6+
## 목표
7+
- 인증된 사용자가 목표를 생성/조회/수정/삭제할 수 있는 GraphQL API
8+
- task BC 미구현 상태이므로 task 진행률 필드는 이후 DataLoader로 추가
9+
10+
## 제약조건
11+
- DDD + Clean Architecture 준수
12+
- TDD (Domain, Application 필수)
13+
- DGS Codegen으로 GraphQL 타입 자동 생성
14+
- GoalId는 common/domain에 배치 (task BC에서도 사용 예정)
15+
- DB 스키마는 docs/schema.sql의 goal 테이블 그대로 사용
16+
17+
## 관련 문서
18+
- docs/schema.sql — DB 스키마 정의
19+
- docs/architecture.md — 아키텍처 가이드
20+
- auth BC — 참조 구현 패턴

docs/plan/goal-crud/plan.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Goal CRUD API 구현 계획
2+
3+
## 단계
4+
5+
- [x] 1단계: GraphQL 스키마 + Flyway 마이그레이션 + Codegen 실행
6+
- [x] 2단계: Domain — GoalId VO (TDD)
7+
- [x] 3단계: Domain — GoalTitle VO (TDD)
8+
- [x] 4단계: Domain — Goal 엔티티 + Command + Query + Repository
9+
- [x] 5단계: Application — GoalService (TDD)
10+
- [x] 6단계: Infrastructure — GoalTable + ExposedGoalRepository
11+
- [x] 7단계: Presentation — GoalDataFetcher
12+
- [x] 8단계: 전체 테스트 통과 검증
13+
- [x] 9단계: GoalTable에 memberId 인덱스 명시
14+
- [x] 10단계: GoalTitle 글자수 제한 제거 (blank 검증만 유지)

docs/plan/graphql-schema-description/plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44

55
- [x] 1단계: auth.graphqls에 description 추가
66
- [x] 2단계: 빌드 검증
7-
- [ ] 3단계: description 제약 조건을 실제 코드와 일치시키기
7+
- [x] 3단계: description 제약 조건을 실제 코드와 일치시키기

spectaql/config.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ introspection:
55
schemaFile:
66
- src/main/resources/schema/schema.graphqls
77
- src/main/resources/schema/auth.graphqls
8+
- src/main/resources/schema/goal.graphqls
89
# - src/main/resources/schema/task.graphqls
910

1011
info:
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package kr.io.team.loop.common.domain
2+
3+
import kr.io.team.loop.common.domain.exception.InvalidInputException
4+
5+
@JvmInline
6+
value class GoalId(
7+
val value: Long,
8+
) {
9+
init {
10+
if (value <= 0) throw InvalidInputException("GoalId must be positive")
11+
}
12+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package kr.io.team.loop.goal.application.service
2+
3+
import kr.io.team.loop.common.domain.MemberId
4+
import kr.io.team.loop.common.domain.exception.AccessDeniedException
5+
import kr.io.team.loop.common.domain.exception.EntityNotFoundException
6+
import kr.io.team.loop.goal.domain.model.Goal
7+
import kr.io.team.loop.goal.domain.model.GoalCommand
8+
import kr.io.team.loop.goal.domain.model.GoalQuery
9+
import kr.io.team.loop.goal.domain.repository.GoalRepository
10+
import org.springframework.stereotype.Service
11+
import org.springframework.transaction.annotation.Transactional
12+
13+
@Service
14+
class GoalService(
15+
private val goalRepository: GoalRepository,
16+
) {
17+
@Transactional
18+
fun create(command: GoalCommand.Create): Goal = goalRepository.save(command)
19+
20+
@Transactional(readOnly = true)
21+
fun findAll(query: GoalQuery): List<Goal> = goalRepository.findAll(query)
22+
23+
@Transactional
24+
fun update(
25+
command: GoalCommand.Update,
26+
memberId: MemberId,
27+
): Goal {
28+
val goal =
29+
goalRepository.findById(command.goalId)
30+
?: throw EntityNotFoundException("Goal not found: ${command.goalId.value}")
31+
if (!goal.isOwnedBy(memberId)) {
32+
throw AccessDeniedException("Goal does not belong to member: ${memberId.value}")
33+
}
34+
return goalRepository.update(command)
35+
}
36+
37+
@Transactional
38+
fun delete(
39+
command: GoalCommand.Delete,
40+
memberId: MemberId,
41+
) {
42+
val goal =
43+
goalRepository.findById(command.goalId)
44+
?: throw EntityNotFoundException("Goal not found: ${command.goalId.value}")
45+
if (!goal.isOwnedBy(memberId)) {
46+
throw AccessDeniedException("Goal does not belong to member: ${memberId.value}")
47+
}
48+
goalRepository.delete(command)
49+
}
50+
}

0 commit comments

Comments
 (0)