From 53410c2212cc74bbdc4d5b44620b429e0a920af6 Mon Sep 17 00:00:00 2001 From: titaniper Date: Tue, 27 Oct 2026 10:40:17 +0900 Subject: [PATCH 1/8] =?UTF-8?q?ci:=20DB=20=EA=B5=AC=EC=A1=B0=EB=A5=BC=20St?= =?UTF-8?q?rata=20=EB=A1=9C=20=E2=80=94=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EA=B2=80=EC=A6=9D=EC=9D=B4=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A0=20=EC=8A=A4=ED=82=A4=EB=A7=88=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=BD=EC=96=B4=20=EB=B3=B4=EB=82=B4=EA=B3=A0=20PR=20?= =?UTF-8?q?=EC=97=90=20beta=20=EB=8C=80=EB=B9=84=20=EC=A0=84=C2=B7?= =?UTF-8?q?=ED=9B=84=20=EC=BD=94=EB=A9=98=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend-migration-check 끝에 2단계: erd.sql 로 구조 읽기 → Strata 에 올리기 → PR 코멘트(하나를 고친다) - continue-on-error — Strata 가 실패해도 마이그레이션 게이트는 그대로 - 포크 PR 은 시크릿이 없어 건너뛴다 - scripts/strata/erd.sql: MySQL 쿼리 하나로 erd.json (언어 무관) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../workflows/backend-migration-check.yaml | 43 +++++++++++++++++++ scripts/strata/erd.sql | 39 +++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 scripts/strata/erd.sql diff --git a/.github/workflows/backend-migration-check.yaml b/.github/workflows/backend-migration-check.yaml index 21e72dc63..6b494507c 100644 --- a/.github/workflows/backend-migration-check.yaml +++ b/.github/workflows/backend-migration-check.yaml @@ -27,6 +27,9 @@ on: jobs: verify: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write services: mysql: # 로컬·운영과 같은 8.0. 값은 CI 안에서만 사는 더미다 (운영 자격증명 아님). @@ -86,6 +89,46 @@ jobs: done echo "❌ 120초 안에 안 떴다"; cat boot.log; kill $pid; exit 1 + # 방금 빈 DB 에 Flyway 가 만든 스키마 = 이 커밋의 진짜 DB 구조. 그대로 읽어 Strata(ERD 지도)로 보낸다. + # PR 이면 beta 대비 바뀐 테이블·컬럼과 위험한 변경(컬럼 삭제 · default 없는 NOT NULL …)을 코멘트로 남긴다. + # 포크 PR 은 시크릿이 없어 건너뛴다. 실패해도 검증 게이트는 막지 않는다 + - name: DB 구조를 Strata 로 (ERD) + id: strata + if: success() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + continue-on-error: true + env: + STRATA_TOKEN: ${{ secrets.STRATA_TOKEN }} + STRATA_API: https://strata-api.igniter.im + STRATA_PROJECT: faccbf09-16da-4012-aeca-6595dadf39c6/projects/fa485ab4-54ec-43bc-a9b5-9b330d30687c + SOURCE: studyclub-engineering:backend + PR: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + mysql --default-character-set=utf8mb4 -h 127.0.0.1 -P 3306 -u studyclub -pci-studyclub studyclub -N -B -r < scripts/strata/erd.sql > erd.json + branch="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" + jq -n --slurpfile erd erd.json --arg source "$SOURCE" --arg repo "$GITHUB_REPOSITORY" --arg branch "$branch" \ + --arg sha "${HEAD_SHA:-$GITHUB_SHA}" --arg pr "$PR" --arg url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR" \ + '{source:$source, repo:$repo, branch:$branch, baseBranch:"beta", sha:$sha, method:"introspect", erd:$erd[0]} + + (if $pr != "" then {pr:($pr|tonumber), prUrl:$url} else {} end)' > body.json + code=$(curl -s -o res.json -w '%{http_code}' -X POST "$STRATA_API/teams/$STRATA_PROJECT/schema/snapshots" \ + -H "authorization: Bearer $STRATA_TOKEN" -H 'content-type: application/json' --data-binary @body.json) + if [ "${code:0:1}" != 2 ]; then echo "::warning::Strata 가 $code 로 거절 — $(head -c 300 res.json)"; exit 1; fi + jq -r .comment res.json > comment.md + cat comment.md >> "$GITHUB_STEP_SUMMARY" + + - name: ERD 변경 PR 코멘트 (있으면 고치고 없으면 만든다) + if: steps.strata.outcome == 'success' && github.event_name == 'pull_request' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + SOURCE: studyclub-engineering:backend + run: | + id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" --paginate \ + --jq ".[] | select(.body | startswith(\"\") and contains(\"### DB 구조 · $SOURCE\")) | .id" | head -1) + if [ -n "$id" ]; then gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -F body=@comment.md >/dev/null + else gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@comment.md >/dev/null; fi + - name: 적용된 스키마 출력 (실패 원인 파악용) if: always() run: | diff --git a/scripts/strata/erd.sql b/scripts/strata/erd.sql new file mode 100644 index 000000000..44706a814 --- /dev/null +++ b/scripts/strata/erd.sql @@ -0,0 +1,39 @@ +-- 현재 DB 의 구조를 erd.json 한 덩어리로 — 언어·ORM 무관 (mysql CLI 만 있으면 된다). Strata specs/schema — 이 파일은 Strata 수집기 사본 +-- mysql --default-character-set=utf8mb4 -h -u -p -N -B -r < erd.sql > erd.json +-- (문자셋을 안 주면 한글 default 가 ?? 로 깨진다) +-- 순서가 필요한 배열은 GROUP_CONCAT … ORDER BY 로 만든다 — JSON_ARRAYAGG 는 순서를 보장하지 않는다 +SET SESSION group_concat_max_len = 16777216; +-- extract.cjs 와 같은 모양. Flyway 기록 테이블은 뺀다 +SELECT JSON_OBJECT( + 'engine', CONCAT('mysql ', SUBSTRING_INDEX(VERSION(), '-', 1)), + 'tables', COALESCE(( + SELECT CAST(CONCAT('[', GROUP_CONCAT(JSON_OBJECT( + 'name', t.TABLE_NAME, + 'columns', ( + SELECT CAST(CONCAT('[', GROUP_CONCAT(JSON_OBJECT( + 'name', c.COLUMN_NAME, + 'type', c.COLUMN_TYPE, + 'nullable', CAST(IF(c.IS_NULLABLE = 'YES', 'true', 'false') AS JSON), + 'default', c.COLUMN_DEFAULT, + 'pk', CAST(IF(c.COLUMN_KEY = 'PRI', 'true', 'false') AS JSON), + 'extra', c.EXTRA) ORDER BY c.ORDINAL_POSITION SEPARATOR ','), ']') AS JSON) + FROM information_schema.COLUMNS c WHERE c.TABLE_SCHEMA = t.TABLE_SCHEMA AND c.TABLE_NAME = t.TABLE_NAME), + 'fks', COALESCE(( + SELECT JSON_ARRAYAGG(JSON_OBJECT('name', k.n, 'columns', k.cols, 'refTable', k.rt, 'refColumns', k.rcols)) + FROM ( + SELECT CONSTRAINT_NAME n, MIN(REFERENCED_TABLE_NAME) rt, + CAST(CONCAT('[', GROUP_CONCAT(JSON_QUOTE(COLUMN_NAME) ORDER BY ORDINAL_POSITION), ']') AS JSON) cols, + CAST(CONCAT('[', GROUP_CONCAT(JSON_QUOTE(REFERENCED_COLUMN_NAME) ORDER BY ORDINAL_POSITION), ']') AS JSON) rcols + FROM information_schema.KEY_COLUMN_USAGE kc + WHERE TABLE_SCHEMA = t.TABLE_SCHEMA AND TABLE_NAME = t.TABLE_NAME AND REFERENCED_TABLE_NAME IS NOT NULL + GROUP BY CONSTRAINT_NAME) k), JSON_ARRAY()), + 'indexes', COALESCE(( + SELECT JSON_ARRAYAGG(JSON_OBJECT('name', i.n, 'columns', i.cols, 'unique', CAST(IF(i.nu = 0, 'true', 'false') AS JSON))) + FROM ( + SELECT INDEX_NAME n, MIN(NON_UNIQUE) nu, CAST(CONCAT('[', GROUP_CONCAT(JSON_QUOTE(COLUMN_NAME) ORDER BY SEQ_IN_INDEX), ']') AS JSON) cols + FROM information_schema.STATISTICS st + WHERE TABLE_SCHEMA = t.TABLE_SCHEMA AND TABLE_NAME = t.TABLE_NAME AND INDEX_NAME <> 'PRIMARY' + GROUP BY INDEX_NAME) i), JSON_ARRAY())) ORDER BY t.TABLE_NAME SEPARATOR ','), ']') AS JSON) + FROM information_schema.TABLES t + WHERE t.TABLE_SCHEMA = DATABASE() AND t.TABLE_TYPE = 'BASE TABLE' AND t.TABLE_NAME NOT IN ('flyway_schema_history')), JSON_ARRAY()) +) AS erd; From 23a9d81b5258417b8f928775c2e8a78b4013d0a3 Mon Sep 17 00:00:00 2001 From: titaniper Date: Tue, 27 Oct 2026 11:06:25 +0900 Subject: [PATCH 2/8] =?UTF-8?q?ci(strata):=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=ED=81=B4=EB=9F=BD=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=B3=84?= =?UTF-8?q?=EC=B9=AD=20strata-api.studyclub.life=20=EB=A1=9C=20=EB=B3=B4?= =?UTF-8?q?=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .github/workflows/backend-migration-check.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backend-migration-check.yaml b/.github/workflows/backend-migration-check.yaml index 6b494507c..8aec2ecab 100644 --- a/.github/workflows/backend-migration-check.yaml +++ b/.github/workflows/backend-migration-check.yaml @@ -98,7 +98,7 @@ jobs: continue-on-error: true env: STRATA_TOKEN: ${{ secrets.STRATA_TOKEN }} - STRATA_API: https://strata-api.igniter.im + STRATA_API: https://strata-api.studyclub.life STRATA_PROJECT: faccbf09-16da-4012-aeca-6595dadf39c6/projects/fa485ab4-54ec-43bc-a9b5-9b330d30687c SOURCE: studyclub-engineering:backend PR: ${{ github.event.pull_request.number }} From 9cb9f7b885ff7139bcb45a30d70b7317935516c0 Mon Sep 17 00:00:00 2001 From: titaniper Date: Tue, 27 Oct 2026 11:08:02 +0900 Subject: [PATCH 3/8] =?UTF-8?q?ci(strata):=20=EB=94=94=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=B4=87=20SQLite=20=EA=B5=AC=EC=A1=B0=EB=8F=84=20?= =?UTF-8?q?Strata=20=EB=A1=9C=20=E2=80=94=20file=20=EB=B0=A9=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - discord/.strata/schema.json: 봇이 만드는 테이블 2개(study_reservation · voice_channel_activity) - tests/test_strata_schema.py: 실제 CREATE TABLE 을 임시 SQLite 에 돌려 PRAGMA 로 대조, 다르면 실패. 같은 모듈로 파일을 다시 뽑는다 - .github/workflows/discord-strata.yaml: 파일을 그대로 올리고 PR 에 beta 대비 코멘트 - AGENT.md: CREATE TABLE 을 고치면 같은 커밋에서 schema.json 도 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/discord-strata.yaml | 56 ++++++++++++++++++++ AGENT.md | 3 ++ discord/.strata/schema.json | 75 +++++++++++++++++++++++++++ discord/tests/test_strata_schema.py | 57 ++++++++++++++++++++ 4 files changed, 191 insertions(+) create mode 100644 .github/workflows/discord-strata.yaml create mode 100644 discord/.strata/schema.json create mode 100644 discord/tests/test_strata_schema.py diff --git a/.github/workflows/discord-strata.yaml b/.github/workflows/discord-strata.yaml new file mode 100644 index 000000000..f811b9223 --- /dev/null +++ b/.github/workflows/discord-strata.yaml @@ -0,0 +1,56 @@ +# 디스코드 봇의 SQLite 구조(ERD)를 Strata 로 보낸다 — file 방식: DB 를 띄우지 않고 discord/.strata/schema.json 을 그대로 보낸다. +# 그 파일이 실제 CREATE TABLE 과 맞는지는 discord-PR-CI 의 pytest(tests/test_strata_schema.py)가 막는다. +# PR 이면 beta 대비 전·후를 코멘트로 남긴다. beta push 는 비교 기준 스냅샷이다. +name: discord-strata + +on: + pull_request: + branches: [beta, develop, main] + paths: ['discord/app/**', 'discord/.strata/**', '.github/workflows/discord-strata.yaml'] + push: + branches: [beta] + paths: ['discord/.strata/**'] + workflow_dispatch: + +env: + STRATA_API: https://strata-api.studyclub.life + STRATA_PROJECT: faccbf09-16da-4012-aeca-6595dadf39c6/projects/fa485ab4-54ec-43bc-a9b5-9b330d30687c + SOURCE: studyclub-engineering:discord + SCHEMA: discord/.strata/schema.json + +jobs: + schema: + # 포크 PR 에는 시크릿이 없다 + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + - name: Strata 에 올리기 + env: + STRATA_TOKEN: ${{ secrets.STRATA_TOKEN }} + PR: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + branch="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" + jq -n --slurpfile erd "$SCHEMA" --arg source "$SOURCE" --arg repo "$GITHUB_REPOSITORY" --arg branch "$branch" \ + --arg sha "${HEAD_SHA:-$GITHUB_SHA}" --arg pr "$PR" --arg url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR" \ + '{source:$source, repo:$repo, branch:$branch, baseBranch:"beta", sha:$sha, method:"file", erd:$erd[0]} + + (if $pr != "" then {pr:($pr|tonumber), prUrl:$url} else {} end)' > body.json + code=$(curl -s -o res.json -w '%{http_code}' -X POST "$STRATA_API/teams/$STRATA_PROJECT/schema/snapshots" \ + -H "authorization: Bearer $STRATA_TOKEN" -H 'content-type: application/json' --data-binary @body.json) + if [ "${code:0:1}" != 2 ]; then echo "::error::Strata 가 $code 로 거절 — $(head -c 300 res.json)"; exit 1; fi + jq -r .comment res.json > comment.md + cat comment.md >> "$GITHUB_STEP_SUMMARY" + - name: PR 코멘트 (있으면 고치고 없으면 만든다) + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: | + id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" --paginate \ + --jq ".[] | select(.body | startswith(\"\") and contains(\"### DB 구조 · $SOURCE\")) | .id" | head -1) + if [ -n "$id" ]; then gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -F body=@comment.md >/dev/null + else gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@comment.md >/dev/null; fi diff --git a/AGENT.md b/AGENT.md index 3776d03af..2b86987b5 100644 --- a/AGENT.md +++ b/AGENT.md @@ -154,6 +154,9 @@ cd backend && ./gradlew :api:bootRun # JDK 25 필요. Gradle 은 wrapp **playground 만 `beta` 브랜치에서 배포된다** (`playground-beta.yaml`) — 프로토타입이라 main 을 기다리지 않는다. `backend-PR-CI` 는 PR 마다 `./gradlew check` 를 돌린다 — 컴파일 실패·포맷 위반이 머지되는 걸 막는 게이트. `backend-migration-check` 는 PR 마다 빈 MySQL 에 마이그레이션을 적용해 본다 — 여기서 깨지면 `V*.sql` 을 고친다. +- **디스코드 봇의 `CREATE TABLE` 을 고치면 같은 커밋에서 `discord/.strata/schema.json` 도 다시 뽑는다** — + `cd discord && python -m tests.test_strata_schema > .strata/schema.json`. 봇은 백엔드 MySQL 과 별개로 SQLite 테이블을 쓰고, + Strata(DB 구조 지도)는 이 파일을 그대로 읽는다(`discord-strata`). 파일이 실제 테이블과 다르면 `discord-PR-CI` 의 pytest 가 막는다. --- diff --git a/discord/.strata/schema.json b/discord/.strata/schema.json new file mode 100644 index 000000000..cbb7ea604 --- /dev/null +++ b/discord/.strata/schema.json @@ -0,0 +1,75 @@ +{ + "engine": "sqlite 3", + "tables": [ + { + "name": "study_reservation", + "columns": [ + { + "name": "study_name", + "type": "text", + "nullable": false, + "default": null, + "pk": true + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null + }, + { + "name": "idempotency_key", + "type": "text", + "nullable": false, + "default": null + }, + { + "name": "study_id", + "type": "integer", + "nullable": true, + "default": null + }, + { + "name": "role_id", + "type": "integer", + "nullable": true, + "default": null + } + ], + "fks": [], + "indexes": [] + }, + { + "name": "voice_channel_activity", + "columns": [ + { + "name": "channel_id", + "type": "integer", + "nullable": false, + "default": null, + "pk": true + }, + { + "name": "first_seen_at", + "type": "text", + "nullable": false, + "default": null + }, + { + "name": "last_activity_at", + "type": "text", + "nullable": true, + "default": null + }, + { + "name": "last_alerted_at", + "type": "text", + "nullable": true, + "default": null + } + ], + "fks": [], + "indexes": [] + } + ] +} diff --git a/discord/tests/test_strata_schema.py b/discord/tests/test_strata_schema.py new file mode 100644 index 000000000..10d9de52f --- /dev/null +++ b/discord/tests/test_strata_schema.py @@ -0,0 +1,57 @@ +"""discord/.strata/schema.json matches the tables the bot really creates. + +Strata (the DB-structure map) reads that file as-is; nothing here runs a DB in CI +for it. So the file is hand- (or AI-) maintained next to the CREATE TABLE +statements, and this test is what keeps the two from drifting: it lets each +store create its table in a scratch SQLite file and compares PRAGMA output. + +After changing a CREATE TABLE, regenerate with: + python -m tests.test_strata_schema > .strata/schema.json +""" + +from __future__ import annotations + +import json +import sqlite3 +import tempfile +from pathlib import Path + +from app.study_reservations import StudyReservations +from app.voice_activity_log import VoiceActivityLog + +SCHEMA = Path(__file__).resolve().parents[1] / ".strata" / "schema.json" +STORES = (StudyReservations, VoiceActivityLog) + + +def actual() -> dict: + """The ERD of a fresh SQLite file after every store has created its table.""" + with tempfile.TemporaryDirectory() as tmp: + path = str(Path(tmp) / "discord.sqlite3") + for store in STORES: + store(path)._connect().close() + conn = sqlite3.connect(path) + names = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")] + tables = [] + for name in names: + columns = [ + {"name": c, "type": t.lower(), "nullable": not (notnull or pk), "default": d, **({"pk": True} if pk else {})} + for _, c, t, notnull, d, pk in conn.execute(f"PRAGMA table_info({name})") + ] + fks = [ + {"name": f"fk_{name}_{i}", "columns": [frm], "refTable": ref, "refColumns": [to]} + for i, _, ref, frm, to, *_ in conn.execute(f"PRAGMA foreign_key_list({name})") + ] + tables.append({"name": name, "columns": columns, "fks": fks, "indexes": []}) + version = conn.execute("SELECT sqlite_version()").fetchone()[0].split(".")[0] + conn.close() + return {"engine": f"sqlite {version}", "tables": tables} + + +def test_schema_file_matches_the_tables_the_bot_creates(): + assert json.loads(SCHEMA.read_text()) == actual(), ( + "discord/.strata/schema.json is stale -- run: python -m tests.test_strata_schema > .strata/schema.json" + ) + + +if __name__ == "__main__": + print(json.dumps(actual(), ensure_ascii=False, indent=2)) From 74f6167d94e6b53200c0b904282ab238d7bc3c5a Mon Sep 17 00:00:00 2001 From: titaniper Date: Tue, 27 Oct 2026 12:17:58 +0900 Subject: [PATCH 4/8] =?UTF-8?q?ci(strata):=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EC=9C=84=EC=B8=B5=20=E2=80=94=20=EA=B0=9C=EB=85=90=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=208=20=C2=B7=20=EC=88=A8=EC=9D=80=20=EA=B4=80?= =?UTF-8?q?=EA=B3=84=209=20=C2=B7=20=ED=9D=90=EB=A6=84=203,=20=EC=8A=A4?= =?UTF-8?q?=ED=8E=99=C2=B7docs/erd=20=EC=99=80=20=ED=95=A8=EA=BB=98=20?= =?UTF-8?q?=EC=98=AC=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .strata/concepts.json: 계정 · 스터디 운영 · 모집 · 제안 · 활동 · 알림 · 운영 · 디스코드 봇. CREATED_BY·RECIPIENT_USER_ID·UPDATED_BY_ADMIN_ID → ACCOUNT (docs/erd 근거), 디스코드·구글 ID 는 시스템 밖 (봇 study_reservation.study_id 가 backend STUDY 로 잘못 짐작되던 것을 끊는다) - .strata/flows: 스터디 신청 · 디스코드 출석 체크 · 회원 탈퇴 — 각 스펙의 처리 순서 - strata-knowledge 워크플로 + scripts/strata/collect-knowledge.cjs (strata-engineering 이 비공개라 사본) - AGENT.md: 테이블·처리 순서를 바꾸면 같은 PR 에서 .strata 도 dry-run (2026-10-27): 문서 53 중 47 이 테이블과 이어짐, 경고 2 — docs/erd 의 SESSION·SITE_NOTICE 는 DB 에 없다 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/strata-knowledge.yaml | 33 +++++++++ .strata/concepts.json | 23 ++++++ .strata/flows/discord-attendance.json | 13 ++++ .strata/flows/study-apply.json | 14 ++++ .strata/flows/user-leave.json | 19 +++++ AGENT.md | 3 + scripts/strata/collect-knowledge.cjs | 95 +++++++++++++++++++++++++ 7 files changed, 200 insertions(+) create mode 100644 .github/workflows/strata-knowledge.yaml create mode 100644 .strata/concepts.json create mode 100644 .strata/flows/discord-attendance.json create mode 100644 .strata/flows/study-apply.json create mode 100644 .strata/flows/user-leave.json create mode 100644 scripts/strata/collect-knowledge.cjs diff --git a/.github/workflows/strata-knowledge.yaml b/.github/workflows/strata-knowledge.yaml new file mode 100644 index 000000000..a261b67e3 --- /dev/null +++ b/.github/workflows/strata-knowledge.yaml @@ -0,0 +1,33 @@ +# 지도 위층(문서 · 개념 · 흐름)을 Strata 로 보낸다 — specs/*/spec.md · docs/erd · docs/flows 와 .strata/concepts.json · .strata/flows/*.json. +# 테이블과 잇는 건 Strata 가 읽을 때 한다. 적힌 테이블이 DB 구조에 없으면 경고로 찍는다(실패는 아니다). +# beta push 가 지도의 기준이다. AGENT.md §작업 룰 「.strata」 +name: strata-knowledge + +on: + pull_request: + branches: [beta, develop, main] + paths: ['specs/**', 'docs/erd/**', 'docs/flows/**', '.strata/concepts.json', '.strata/flows/**', 'scripts/strata/collect-knowledge.cjs', '.github/workflows/strata-knowledge.yaml'] + push: + branches: [beta] + paths: ['specs/**', 'docs/erd/**', 'docs/flows/**', '.strata/concepts.json', '.strata/flows/**'] + workflow_dispatch: + +jobs: + knowledge: + # 포크 PR 에는 시크릿이 없다 + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Strata 에 올리기 + env: + STRATA_API: https://strata-api.studyclub.life + STRATA_TOKEN: ${{ secrets.STRATA_TOKEN }} + STRATA_PROJECT: faccbf09-16da-4012-aeca-6595dadf39c6/projects/fa485ab4-54ec-43bc-a9b5-9b330d30687c + BASE_BRANCH: beta + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node scripts/strata/collect-knowledge.cjs diff --git a/.strata/concepts.json b/.strata/concepts.json new file mode 100644 index 000000000..f7d5e70b3 --- /dev/null +++ b/.strata/concepts.json @@ -0,0 +1,23 @@ +{ + "concepts": [ + { "name": "계정", "summary": "가입한 크루 한 명 — 로그인 수단(구글), 약관 동의, 탈퇴 사유", "tables": ["ACCOUNT", "ACCOUNT_IDENTITY", "ACCOUNT_CONSENT", "ACCOUNT_LEAVE_REASON"] }, + { "name": "스터디 운영", "summary": "프로그램(정체성) 아래 기수별 스터디, 스터디 안에 반과 모임, 모임마다 출석. 명부(참가자)는 반에 속한다", "tables": ["STUDY_PROGRAM", "STUDY", "STUDY_GROUP", "STUDY_MEETING", "STUDY_PARTICIPANT", "STUDY_ATTENDANCE"] }, + { "name": "모집", "summary": "기수의 모집 회차와 신청서. 신청 제출 = 기본 반 명부 편입", "tables": ["STUDY_RECRUITMENT", "STUDY_APPLICATION"] }, + { "name": "제안", "summary": "크루가 열고 싶은 스터디를 제안하고 다른 크루가 관심을 표시", "tables": ["STUDY_PROPOSAL", "STUDY_PROPOSAL_INTEREST"] }, + { "name": "활동", "summary": "스터디에 남기는 후기와 북마크", "tables": ["STUDY_REVIEW", "STUDY_BOOKMARK"] }, + { "name": "알림", "summary": "메일 등 발송 한 건과 그 문구 템플릿", "tables": ["NOTIFICATION", "NOTIFICATION_TEMPLATE"] }, + { "name": "운영", "summary": "관리자 감사 로그, 올린 파일, 스터디 ↔ 디스코드 카테고리 연결", "tables": ["ADMIN_AUDIT_LOG", "STORAGE_OBJECT", "STUDY_DISCORD_LINK"] }, + { "name": "디스코드 봇", "summary": "봇이 SQLite 에 따로 두는 것 — 스터디 이름 예약, 공부방 활동 시각", "tables": ["discord/study_reservation", "discord/voice_channel_activity"] } + ], + "relations": [ + { "from": "STUDY.CREATED_BY", "to": "ACCOUNT", "why": "이 기수를 등록한 계정 = 담당 캡틴 (docs/erd/STUDY.md)" }, + { "from": "NOTIFICATION.RECIPIENT_USER_ID", "to": "ACCOUNT", "why": "받는 사람. 애그리거트 사이라 FK 없음 (docs/erd/NOTIFICATION.md)" }, + { "from": "NOTIFICATION_TEMPLATE.UPDATED_BY_ADMIN_ID", "to": "ACCOUNT", "why": "마지막으로 고친 관리자 (docs/erd/NOTIFICATION_TEMPLATE.md)" }, + { "from": "ACCOUNT.DISCORD_ID", "to": "discord:user", "why": "디스코드 유저 snowflake — 출석 체크가 이걸로 계정을 찾는다" }, + { "from": "STUDY_DISCORD_LINK.DISCORD_STUDY_ID", "to": "discord:category", "why": "스터디 카테고리 채널 snowflake" }, + { "from": "STUDY_DISCORD_LINK.DISCORD_ROLE_ID", "to": "discord:role", "why": "스터디 전용 역할 snowflake" }, + { "from": "ACCOUNT_IDENTITY.PROVIDER_ACCOUNT_ID", "to": "google:account", "why": "OAuth sub" }, + { "from": "discord/study_reservation.study_id", "to": "discord:category", "why": "봇이 만든 카테고리 ID — backend STUDY 가 아니다" }, + { "from": "discord/study_reservation.role_id", "to": "discord:role", "why": "봇이 만든 역할 ID" } + ] +} diff --git a/.strata/flows/discord-attendance.json b/.strata/flows/discord-attendance.json new file mode 100644 index 000000000..927830c21 --- /dev/null +++ b/.strata/flows/discord-attendance.json @@ -0,0 +1,13 @@ +{ + "name": "디스코드 출석 체크", + "trigger": "POST /api/discord/studies/{discordStudyId}/attendances (봇, X-API-Key)", + "spec": "specs/discord-attendance/spec.md", + "steps": [ + { "external": "discord", "note": "반장이 공부방 채팅에서 !출석체크 → 봇이 접속자 스냅샷을 보낸다" }, + { "table": "STUDY_DISCORD_LINK", "op": "read", "note": "디스코드 카테고리 → 스터디" }, + { "table": "ACCOUNT", "op": "read", "note": "discordUserId → ACCOUNT.DISCORD_ID → 계정. 없으면 unmatched" }, + { "table": "STUDY_PARTICIPANT", "op": "read", "note": "그 반 참가자인가 (ACTIVE · PAUSED)" }, + { "table": "STUDY_MEETING", "op": "lock", "note": "지금 모이는 회차를 백엔드가 고른다. 예정 회차 자동 시작만 잠금" }, + { "table": "STUDY_ATTENDANCE", "op": "upsert", "note": "PRESENT — ABSENT·없는 행만. LATE·EXCUSED 는 덮지 않는다 (한 문장 upsert)" } + ] +} diff --git a/.strata/flows/study-apply.json b/.strata/flows/study-apply.json new file mode 100644 index 000000000..50306eaeb --- /dev/null +++ b/.strata/flows/study-apply.json @@ -0,0 +1,14 @@ +{ + "name": "스터디 신청", + "trigger": "POST /api/studies/{studyId}/applications", + "spec": "specs/study-application/spec.md", + "steps": [ + { "table": "ACCOUNT", "op": "lock", "note": "디스코드 연동 확인 · 같은 기수 기존 신청 검사 (계정 행 잠금)" }, + { "table": "STUDY_RECRUITMENT", "op": "lock", "note": "열린 모집 회차 · 정원 검사 (마지막 자리 동시 신청 방지)" }, + { "table": "STUDY_APPLICATION", "op": "insert", "note": "신청서 저장 (FORM_ANSWER)" }, + { "table": "STUDY_GROUP", "op": "read", "note": "기본 반 = id 가 가장 작은 반. 없으면 「기본반」을 만든다" }, + { "table": "STUDY_PARTICIPANT", "op": "upsert", "note": "명부 편입 — ACTIVE · MEMBER. 남은 행이 있으면 되살린다" }, + { "table": "ACCOUNT", "op": "update", "note": "디스코드 별명 갱신" }, + { "external": "discord", "event": "study_participant.joined", "note": "커밋 뒤 스터디 역할 부여 (DSC-07)" } + ] +} diff --git a/.strata/flows/user-leave.json b/.strata/flows/user-leave.json new file mode 100644 index 000000000..08192ae82 --- /dev/null +++ b/.strata/flows/user-leave.json @@ -0,0 +1,19 @@ +{ + "name": "회원 탈퇴", + "trigger": "DELETE /api/me", + "spec": "specs/user-leave/spec.md", + "steps": [ + { "table": "ACCOUNT", "op": "read", "note": "본인 계정. 이후 전부 한 트랜잭션" }, + { "table": "ACCOUNT_LEAVE_REASON", "op": "insert", "note": "사유만 — accountId 는 담지 않는다" }, + { "table": "ACCOUNT_IDENTITY", "op": "delete", "note": "로그인 수단 파기 (재가입 가능하게)" }, + { "table": "STUDY_PARTICIPANT", "op": "update", "note": "DELETED + LEFT_AT — 물리 삭제하면 출석 이력이 사라진다" }, + { "table": "STUDY_BOOKMARK", "op": "delete" }, + { "table": "STUDY_PROPOSAL_INTEREST", "op": "delete" }, + { "table": "NOTIFICATION", "op": "update", "note": "받는 사람 비식별화, PENDING 은 CANCELLED" }, + { "table": "STUDY_PROPOSAL", "op": "update", "note": "OPEN → CLOSED" }, + { "table": "STUDY_APPLICATION", "op": "update", "note": "FORM_ANSWER.discordNickname 마스킹" }, + { "table": "ACCOUNT_CONSENT", "op": "delete" }, + { "table": "ACCOUNT", "op": "delete", "note": "프로필 파기. 사진 주소는 먼저 읽어 둔다" }, + { "table": "STORAGE_OBJECT", "op": "update", "note": "올린 사진 DELETING → 커밋 뒤 S3 파일과 행 삭제" } + ] +} diff --git a/AGENT.md b/AGENT.md index 2b86987b5..9fd474521 100644 --- a/AGENT.md +++ b/AGENT.md @@ -157,6 +157,9 @@ cd backend && ./gradlew :api:bootRun # JDK 25 필요. Gradle 은 wrapp - **디스코드 봇의 `CREATE TABLE` 을 고치면 같은 커밋에서 `discord/.strata/schema.json` 도 다시 뽑는다** — `cd discord && python -m tests.test_strata_schema > .strata/schema.json`. 봇은 백엔드 MySQL 과 별개로 SQLite 테이블을 쓰고, Strata(DB 구조 지도)는 이 파일을 그대로 읽는다(`discord-strata`). 파일이 실제 테이블과 다르면 `discord-PR-CI` 의 pytest 가 막는다. +- **`.strata/` 는 Strata 지도의 위층이다** — `concepts.json` 은 테이블을 개념 묶음으로 나누고 이름으로 못 찾는 관계(`CREATED_BY` → ACCOUNT, 디스코드 snowflake 등)를 적는다. + `flows/*.json` 은 흐름 하나의 단계 순서(테이블 · op · 이벤트 · 시스템 밖). 테이블을 추가·삭제·이름 변경했거나 스펙의 처리 순서를 바꿨으면 같은 PR 에서 고친다. + `strata-knowledge` CI 가 스펙·`docs/erd`·`docs/flows` 와 함께 올리고, DB 구조에 없는 테이블을 가리키면 경고를 찍는다. --- diff --git a/scripts/strata/collect-knowledge.cjs b/scripts/strata/collect-knowledge.cjs new file mode 100644 index 000000000..5fda20385 --- /dev/null +++ b/scripts/strata/collect-knowledge.cjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +// 사본 — 정본은 IgniterCorp/strata-engineering actions/knowledge-snapshot/collect.cjs (그 레포가 비공개라 액션으로 못 부른다) +// 레포의 위층(문서 · 개념 · 흐름)을 모아 Strata 에 올린다. specs/schema §위층 +// node collect.cjs → 올린다 (env STRATA_API · STRATA_TOKEN · STRATA_PROJECT=<팀>/projects/<프로젝트>) +// node collect.cjs --dry-run → 저장하지 않고 "DB 구조에 없는 테이블" 경고만 본다 (로컬 검사) +// node collect.cjs --print → 보낼 body 만 찍는다 (네트워크 없음) +// 읽는 것 (레포 루트 기준, env 로 바꿀 수 있다) +// STRATA_DOCS = specs/*/spec.md,docs/erd/*.md,docs/flows/*.md +// .strata/concepts.json = { concepts: [{ name, summary?, tables[] }], relations: [{ from: 'TABLE.COLUMN', to, why? }] } +// .strata/flows/*.json = { name, trigger?, spec?, steps: [{ table? | external?, op?, event?, note? }] } +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const root = process.env.REPO_ROOT || process.cwd(); +const args = process.argv.slice(2); +const git = (...a) => { + try { + return execFileSync('git', ['-C', root, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + return ''; + } +}; + +// a/별/b.md · a/별.md 정도만 — 별 하나가 디렉토리 한 칸 또는 파일 이름 +function glob(pattern) { + let paths = ['']; + for (const part of pattern.split('/')) { + const next = []; + for (const p of paths) { + const dir = path.join(root, p); + if (!part.includes('*')) { + if (fs.existsSync(path.join(dir, part))) next.push(path.join(p, part)); + continue; + } + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) continue; + const re = new RegExp('^' + part.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*') + '$'); + for (const name of fs.readdirSync(dir).sort()) if (re.test(name)) next.push(path.join(p, name)); + } + paths = next; + } + return paths.filter((p) => fs.statSync(path.join(root, p)).isFile()); +} + +const readJson = (p) => JSON.parse(fs.readFileSync(path.join(root, p), 'utf8')); +const patterns = (process.env.STRATA_DOCS || 'specs/*/spec.md,docs/erd/*.md,docs/flows/*.md').split(',').filter(Boolean); +const docs = [...new Set(patterns.flatMap(glob))].map((p) => ({ path: p, body: fs.readFileSync(path.join(root, p), 'utf8') })); +const concepts = fs.existsSync(path.join(root, '.strata/concepts.json')) ? readJson('.strata/concepts.json') : { concepts: [], relations: [] }; +const flows = glob('.strata/flows/*.json').map((p) => { + const f = readJson(p); + if (!f.name || !Array.isArray(f.steps)) throw new Error(`${p}: { name, steps: [...] } 모양이 아니다`); + return f; +}); + +const remote = git('remote', 'get-url', 'origin').match(/[:/]([^/:]+\/[^/]+?)(\.git)?$/)?.[1]; +const body = { + repo: process.env.GITHUB_REPOSITORY || remote, + branch: process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || git('branch', '--show-current'), + baseBranch: process.env.BASE_BRANCH || 'main', + sha: process.env.HEAD_SHA || process.env.GITHUB_SHA || git('rev-parse', 'HEAD'), + ...(process.env.PR_NUMBER ? { pr: Number(process.env.PR_NUMBER) } : {}), + concepts: concepts.concepts ?? [], + relations: concepts.relations ?? [], + flows, + docs, + ...(args.includes('--dry-run') ? { dryRun: true } : {}), +}; + +if (args.includes('--print')) { + console.log(JSON.stringify({ ...body, docs: body.docs.map((d) => ({ path: d.path, bytes: d.body.length })) }, null, 2)); + process.exit(0); +} + +(async () => { + const api = (process.env.STRATA_API || 'https://strata-api.igniter.im').replace(/\/$/, ''); + const res = await fetch(`${api}/teams/${process.env.STRATA_PROJECT}/schema/knowledge`, { + method: 'POST', + headers: { authorization: `Bearer ${process.env.STRATA_TOKEN}`, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const text = await res.text(); + if (!res.ok) { + console.log(`::error::Strata 가 ${res.status} 로 거절 — ${text.slice(0, 300)}`); + process.exit(1); + } + const out = JSON.parse(text); + for (const w of out.warnings) console.log(`::warning::${w}`); + const c = out.counts; + console.log(`${out.saved ? '올렸다' : '검사만 했다'} — 개념 ${c.concepts} · 선언 관계 ${c.relations} · 흐름 ${c.flows} · 문서 ${c.docs}(테이블과 이어짐 ${c.linkedDocs}) · 경고 ${out.warnings.length}`); + if (process.env.GITHUB_STEP_SUMMARY) + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `### Strata 위층 · ${body.repo}\n\n개념 ${c.concepts} · 흐름 ${c.flows} · 문서 ${c.docs} (테이블과 이어짐 ${c.linkedDocs})\n\n${out.warnings.map((w) => `- ⚠️ ${w}`).join('\n')}\n`); +})().catch((e) => { + console.log(`::error::${e.message}`); + process.exit(1); +}); From b64a616020a044b8bd129d8e131fcef8aa220513 Mon Sep 17 00:00:00 2001 From: titaniper Date: Wed, 28 Oct 2026 16:29:45 +0900 Subject: [PATCH 5/8] =?UTF-8?q?chore(strata):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=EB=A5=BC=20StudyClub=20=ED=8C=80=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=98=AE=EA=B9=80=20=E2=80=94=20=ED=8C=80=20ID=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .github/workflows/backend-migration-check.yaml | 2 +- .github/workflows/discord-strata.yaml | 2 +- .github/workflows/strata-knowledge.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/backend-migration-check.yaml b/.github/workflows/backend-migration-check.yaml index 8aec2ecab..5c1d3b4fc 100644 --- a/.github/workflows/backend-migration-check.yaml +++ b/.github/workflows/backend-migration-check.yaml @@ -99,7 +99,7 @@ jobs: env: STRATA_TOKEN: ${{ secrets.STRATA_TOKEN }} STRATA_API: https://strata-api.studyclub.life - STRATA_PROJECT: faccbf09-16da-4012-aeca-6595dadf39c6/projects/fa485ab4-54ec-43bc-a9b5-9b330d30687c + STRATA_PROJECT: a6cb62a5-1e29-4d59-b76a-5ceba5119580/projects/fa485ab4-54ec-43bc-a9b5-9b330d30687c SOURCE: studyclub-engineering:backend PR: ${{ github.event.pull_request.number }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} diff --git a/.github/workflows/discord-strata.yaml b/.github/workflows/discord-strata.yaml index f811b9223..6f024cddb 100644 --- a/.github/workflows/discord-strata.yaml +++ b/.github/workflows/discord-strata.yaml @@ -14,7 +14,7 @@ on: env: STRATA_API: https://strata-api.studyclub.life - STRATA_PROJECT: faccbf09-16da-4012-aeca-6595dadf39c6/projects/fa485ab4-54ec-43bc-a9b5-9b330d30687c + STRATA_PROJECT: a6cb62a5-1e29-4d59-b76a-5ceba5119580/projects/fa485ab4-54ec-43bc-a9b5-9b330d30687c SOURCE: studyclub-engineering:discord SCHEMA: discord/.strata/schema.json diff --git a/.github/workflows/strata-knowledge.yaml b/.github/workflows/strata-knowledge.yaml index a261b67e3..57593c782 100644 --- a/.github/workflows/strata-knowledge.yaml +++ b/.github/workflows/strata-knowledge.yaml @@ -26,7 +26,7 @@ jobs: env: STRATA_API: https://strata-api.studyclub.life STRATA_TOKEN: ${{ secrets.STRATA_TOKEN }} - STRATA_PROJECT: faccbf09-16da-4012-aeca-6595dadf39c6/projects/fa485ab4-54ec-43bc-a9b5-9b330d30687c + STRATA_PROJECT: a6cb62a5-1e29-4d59-b76a-5ceba5119580/projects/fa485ab4-54ec-43bc-a9b5-9b330d30687c BASE_BRANCH: beta PR_NUMBER: ${{ github.event.pull_request.number }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} From b4a9630c8c4ca09051087c6beba9ea958b8b9694 Mon Sep 17 00:00:00 2001 From: titaniper Date: Thu, 29 Oct 2026 23:17:23 +0900 Subject: [PATCH 6/8] =?UTF-8?q?ci:=20Strata=20=EC=97=B0=EA=B2=B0=EC=9D=84?= =?UTF-8?q?=20Prober=20=EB=A1=9C=20=EB=8B=A4=EC=8B=9C=20=EA=B9=90=EB=8B=A4?= =?UTF-8?q?=20=E2=80=94=20prober-map.yml=20=C2=B7=20PROBER=5FTOKEN=20?= =?UTF-8?q?=C2=B7=20.prober/map/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 워크플로를 prober-map.yml 하나로 (changes · schema · knowledge · pr job). pr job 이 PR 열림 · 고침 · 닫힘 · 머지를 Prober 에 올린다 - 시크릿 STRATA_TOKEN → PROBER_TOKEN, API api.prober.im, 프로젝트 id 는 Prober 쪽 - 지도 파일 .strata/ → .prober/map/ - PR 코멘트는 옛 표식()도 찾아 같은 코멘트를 고친다 Strata 는 Prober 로 합쳤다 (prober-engineering specs/strata-merge) Co-Authored-By: Claude Opus 5.5 --- .../workflows/backend-migration-check.yaml | 24 ++-- .github/workflows/discord-strata.yaml | 56 -------- .github/workflows/prober-map.yml | 134 ++++++++++++++++++ .github/workflows/strata-knowledge.yaml | 33 ----- {.strata => .prober/map}/concepts.json | 0 .../map}/flows/discord-attendance.json | 0 .../map}/flows/study-apply.json | 0 .../map}/flows/user-leave.json | 0 AGENT.md | 10 +- discord/{.strata => .prober/map}/schema.json | 0 ...strata_schema.py => test_prober_schema.py} | 10 +- .../{strata => prober}/collect-knowledge.cjs | 45 ++++-- scripts/{strata => prober}/erd.sql | 2 +- 13 files changed, 188 insertions(+), 126 deletions(-) delete mode 100644 .github/workflows/discord-strata.yaml create mode 100644 .github/workflows/prober-map.yml delete mode 100644 .github/workflows/strata-knowledge.yaml rename {.strata => .prober/map}/concepts.json (100%) rename {.strata => .prober/map}/flows/discord-attendance.json (100%) rename {.strata => .prober/map}/flows/study-apply.json (100%) rename {.strata => .prober/map}/flows/user-leave.json (100%) rename discord/{.strata => .prober/map}/schema.json (100%) rename discord/tests/{test_strata_schema.py => test_prober_schema.py} (82%) rename scripts/{strata => prober}/collect-knowledge.cjs (60%) rename scripts/{strata => prober}/erd.sql (96%) diff --git a/.github/workflows/backend-migration-check.yaml b/.github/workflows/backend-migration-check.yaml index 5c1d3b4fc..e113bd9bf 100644 --- a/.github/workflows/backend-migration-check.yaml +++ b/.github/workflows/backend-migration-check.yaml @@ -89,35 +89,35 @@ jobs: done echo "❌ 120초 안에 안 떴다"; cat boot.log; kill $pid; exit 1 - # 방금 빈 DB 에 Flyway 가 만든 스키마 = 이 커밋의 진짜 DB 구조. 그대로 읽어 Strata(ERD 지도)로 보낸다. + # 방금 빈 DB 에 Flyway 가 만든 스키마 = 이 커밋의 진짜 DB 구조. 그대로 읽어 Prober(ERD 지도)로 보낸다. # PR 이면 beta 대비 바뀐 테이블·컬럼과 위험한 변경(컬럼 삭제 · default 없는 NOT NULL …)을 코멘트로 남긴다. # 포크 PR 은 시크릿이 없어 건너뛴다. 실패해도 검증 게이트는 막지 않는다 - - name: DB 구조를 Strata 로 (ERD) - id: strata + - name: DB 구조를 Prober 로 (ERD) + id: prober if: success() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) continue-on-error: true env: - STRATA_TOKEN: ${{ secrets.STRATA_TOKEN }} - STRATA_API: https://strata-api.studyclub.life - STRATA_PROJECT: a6cb62a5-1e29-4d59-b76a-5ceba5119580/projects/fa485ab4-54ec-43bc-a9b5-9b330d30687c + PROBER_TOKEN: ${{ secrets.PROBER_TOKEN }} + PROBER_API: https://api.prober.im + PROBER_PROJECT: 8a05d363-c757-4bba-90f1-680083d7527b/projects/47d37bd7-a00a-4c6b-9ae9-37aa60f43dfc SOURCE: studyclub-engineering:backend PR: ${{ github.event.pull_request.number }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - mysql --default-character-set=utf8mb4 -h 127.0.0.1 -P 3306 -u studyclub -pci-studyclub studyclub -N -B -r < scripts/strata/erd.sql > erd.json + mysql --default-character-set=utf8mb4 -h 127.0.0.1 -P 3306 -u studyclub -pci-studyclub studyclub -N -B -r < scripts/prober/erd.sql > erd.json branch="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" jq -n --slurpfile erd erd.json --arg source "$SOURCE" --arg repo "$GITHUB_REPOSITORY" --arg branch "$branch" \ --arg sha "${HEAD_SHA:-$GITHUB_SHA}" --arg pr "$PR" --arg url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR" \ '{source:$source, repo:$repo, branch:$branch, baseBranch:"beta", sha:$sha, method:"introspect", erd:$erd[0]} + (if $pr != "" then {pr:($pr|tonumber), prUrl:$url} else {} end)' > body.json - code=$(curl -s -o res.json -w '%{http_code}' -X POST "$STRATA_API/teams/$STRATA_PROJECT/schema/snapshots" \ - -H "authorization: Bearer $STRATA_TOKEN" -H 'content-type: application/json' --data-binary @body.json) - if [ "${code:0:1}" != 2 ]; then echo "::warning::Strata 가 $code 로 거절 — $(head -c 300 res.json)"; exit 1; fi + code=$(curl -s -o res.json -w '%{http_code}' -X POST "$PROBER_API/teams/$PROBER_PROJECT/schema/snapshots" \ + -H "authorization: Bearer $PROBER_TOKEN" -H 'content-type: application/json' --data-binary @body.json) + if [ "${code:0:1}" != 2 ]; then echo "::warning::Prober 가 $code 로 거절 — $(head -c 300 res.json)"; exit 1; fi jq -r .comment res.json > comment.md cat comment.md >> "$GITHUB_STEP_SUMMARY" - name: ERD 변경 PR 코멘트 (있으면 고치고 없으면 만든다) - if: steps.strata.outcome == 'success' && github.event_name == 'pull_request' + if: steps.prober.outcome == 'success' && github.event_name == 'pull_request' continue-on-error: true env: GH_TOKEN: ${{ github.token }} @@ -125,7 +125,7 @@ jobs: SOURCE: studyclub-engineering:backend run: | id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" --paginate \ - --jq ".[] | select(.body | startswith(\"\") and contains(\"### DB 구조 · $SOURCE\")) | .id" | head -1) + --jq ".[] | select(.body | (startswith(\"\") or startswith(\"\")) and contains(\"### DB 구조 · $SOURCE\")) | .id" | head -1) if [ -n "$id" ]; then gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -F body=@comment.md >/dev/null else gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@comment.md >/dev/null; fi diff --git a/.github/workflows/discord-strata.yaml b/.github/workflows/discord-strata.yaml deleted file mode 100644 index 6f024cddb..000000000 --- a/.github/workflows/discord-strata.yaml +++ /dev/null @@ -1,56 +0,0 @@ -# 디스코드 봇의 SQLite 구조(ERD)를 Strata 로 보낸다 — file 방식: DB 를 띄우지 않고 discord/.strata/schema.json 을 그대로 보낸다. -# 그 파일이 실제 CREATE TABLE 과 맞는지는 discord-PR-CI 의 pytest(tests/test_strata_schema.py)가 막는다. -# PR 이면 beta 대비 전·후를 코멘트로 남긴다. beta push 는 비교 기준 스냅샷이다. -name: discord-strata - -on: - pull_request: - branches: [beta, develop, main] - paths: ['discord/app/**', 'discord/.strata/**', '.github/workflows/discord-strata.yaml'] - push: - branches: [beta] - paths: ['discord/.strata/**'] - workflow_dispatch: - -env: - STRATA_API: https://strata-api.studyclub.life - STRATA_PROJECT: a6cb62a5-1e29-4d59-b76a-5ceba5119580/projects/fa485ab4-54ec-43bc-a9b5-9b330d30687c - SOURCE: studyclub-engineering:discord - SCHEMA: discord/.strata/schema.json - -jobs: - schema: - # 포크 PR 에는 시크릿이 없다 - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/checkout@v4 - - name: Strata 에 올리기 - env: - STRATA_TOKEN: ${{ secrets.STRATA_TOKEN }} - PR: ${{ github.event.pull_request.number }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - branch="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" - jq -n --slurpfile erd "$SCHEMA" --arg source "$SOURCE" --arg repo "$GITHUB_REPOSITORY" --arg branch "$branch" \ - --arg sha "${HEAD_SHA:-$GITHUB_SHA}" --arg pr "$PR" --arg url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR" \ - '{source:$source, repo:$repo, branch:$branch, baseBranch:"beta", sha:$sha, method:"file", erd:$erd[0]} - + (if $pr != "" then {pr:($pr|tonumber), prUrl:$url} else {} end)' > body.json - code=$(curl -s -o res.json -w '%{http_code}' -X POST "$STRATA_API/teams/$STRATA_PROJECT/schema/snapshots" \ - -H "authorization: Bearer $STRATA_TOKEN" -H 'content-type: application/json' --data-binary @body.json) - if [ "${code:0:1}" != 2 ]; then echo "::error::Strata 가 $code 로 거절 — $(head -c 300 res.json)"; exit 1; fi - jq -r .comment res.json > comment.md - cat comment.md >> "$GITHUB_STEP_SUMMARY" - - name: PR 코멘트 (있으면 고치고 없으면 만든다) - if: github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ github.token }} - PR: ${{ github.event.pull_request.number }} - run: | - id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" --paginate \ - --jq ".[] | select(.body | startswith(\"\") and contains(\"### DB 구조 · $SOURCE\")) | .id" | head -1) - if [ -n "$id" ]; then gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -F body=@comment.md >/dev/null - else gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@comment.md >/dev/null; fi diff --git a/.github/workflows/prober-map.yml b/.github/workflows/prober-map.yml new file mode 100644 index 000000000..53062d812 --- /dev/null +++ b/.github/workflows/prober-map.yml @@ -0,0 +1,134 @@ +# 시스템 지도를 Prober 로 보낸다 — DB 구조 · 위층(문서 · 개념 · 흐름) · PR. prober:connect (Strata 연결을 다시 깐 것) +# 고친 파일에 따라 job 을 고른다(changes). PR 열림 · 고침 · 닫힘 · 머지는 pr job 이 Prober PR 화면에 올린다 +name: prober-map + +on: + pull_request: + types: [opened, edited, synchronize, reopened, closed] + push: + branches: [beta] + workflow_dispatch: + +jobs: + changes: + # 포크 PR 에는 시크릿이 없다. 닫힘 · 제목 수정은 지도를 다시 올리지 않는다 + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + discord_schema: ${{ steps.f.outputs.discord_schema }} + knowledge: ${{ steps.f.outputs.knowledge }} + steps: + - id: f + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + ACTION: ${{ github.event.action }} + FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + PR: ${{ github.event.pull_request.number }} + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.event.after }} + run: | + if [ "$EVENT" = workflow_dispatch ]; then for kv in discord_schema=true knowledge=true; do echo "$kv" >> "$GITHUB_OUTPUT"; done; exit 0; fi + if [ "$EVENT" = pull_request ]; then + if [ "$FORK" = true ] || [ "$ACTION" = closed ] || [ "$ACTION" = edited ]; then : > files.txt + else gh pr diff "$PR" --repo "$GITHUB_REPOSITORY" --name-only > files.txt; fi + else + gh api "repos/$GITHUB_REPOSITORY/compare/$BEFORE...$AFTER" --jq '.files[].filename' > files.txt || echo .github/workflows/prober-map.yml > files.txt + fi + echo "discord_schema=$(grep -qE '^discord/app/.*|^discord/\.prober/map/.*|^\.github/workflows/prober-map\.yml$' files.txt && echo true || echo false)" >> "$GITHUB_OUTPUT" + echo "knowledge=$(grep -qE '^specs/.*|^docs/erd/.*|^docs/flows/.*|^\.prober/map/concepts\.json$|^\.prober/map/flows/.*|^scripts/prober/collect-knowledge\.cjs$|^scripts/prober/collect-knowledge\.cjs$|^\.github/workflows/prober-map\.yml$' files.txt && echo true || echo false)" >> "$GITHUB_OUTPUT" + + # 디스코드 봇의 SQLite 구조(ERD)를 Prober 로 보낸다 — file 방식: DB 를 띄우지 않고 discord/.prober/map/schema.json 을 그대로 보낸다. + # 그 파일이 실제 CREATE TABLE 과 맞는지는 discord-PR-CI 의 pytest(tests/test_prober_schema.py)가 막는다. + # PR 이면 beta 대비 전·후를 코멘트로 남긴다. beta push 는 비교 기준 스냅샷이다. + discord-schema: + needs: changes + if: needs.changes.outputs.discord_schema == 'true' + runs-on: ubuntu-latest + env: + PROBER_API: https://api.prober.im + PROBER_PROJECT: 8a05d363-c757-4bba-90f1-680083d7527b/projects/47d37bd7-a00a-4c6b-9ae9-37aa60f43dfc + SOURCE: studyclub-engineering:discord + SCHEMA: discord/.prober/map/schema.json + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + - name: Prober 에 올리기 + env: + PROBER_TOKEN: ${{ secrets.PROBER_TOKEN }} + PR: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + branch="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" + jq -n --slurpfile erd "$SCHEMA" --arg source "$SOURCE" --arg repo "$GITHUB_REPOSITORY" --arg branch "$branch" \ + --arg sha "${HEAD_SHA:-$GITHUB_SHA}" --arg pr "$PR" --arg url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR" \ + '{source:$source, repo:$repo, branch:$branch, baseBranch:"beta", sha:$sha, method:"file", erd:$erd[0]} + + (if $pr != "" then {pr:($pr|tonumber), prUrl:$url} else {} end)' > body.json + code=$(curl -s -o res.json -w '%{http_code}' -X POST "$PROBER_API/teams/$PROBER_PROJECT/schema/snapshots" \ + -H "authorization: Bearer $PROBER_TOKEN" -H 'content-type: application/json' --data-binary @body.json) + if [ "${code:0:1}" != 2 ]; then echo "::error::Prober 가 $code 로 거절 — $(head -c 300 res.json)"; exit 1; fi + jq -r .comment res.json > comment.md + cat comment.md >> "$GITHUB_STEP_SUMMARY" + - name: PR 코멘트 (있으면 고치고 없으면 만든다) + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: | + id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" --paginate \ + --jq ".[] | select(.body | (startswith(\"\") or startswith(\"\")) and contains(\"### DB 구조 · $SOURCE\")) | .id" | head -1) + if [ -n "$id" ]; then gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -F body=@comment.md >/dev/null + else gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@comment.md >/dev/null; fi + + # 지도 위층(문서 · 개념 · 흐름)을 Prober 로 보낸다 — specs/*/spec.md · docs/erd · docs/flows 와 .prober/map/concepts.json · .prober/map/flows/*.json. + # 테이블과 잇는 건 Prober 가 읽을 때 한다. 적힌 테이블이 DB 구조에 없으면 경고로 찍는다(실패는 아니다). + # beta push 가 지도의 기준이다. AGENT.md §작업 룰 「.prober/map」 + knowledge: + needs: changes + if: needs.changes.outputs.knowledge == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Prober 에 올리기 + env: + PROBER_API: https://api.prober.im + PROBER_TOKEN: ${{ secrets.PROBER_TOKEN }} + PROBER_PROJECT: 8a05d363-c757-4bba-90f1-680083d7527b/projects/47d37bd7-a00a-4c6b-9ae9-37aa60f43dfc + BASE_BRANCH: beta + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node scripts/prober/collect-knowledge.cjs + + pr: + # PR 화면 — 열림 · 고침 · 닫힘 · 머지. PUT 은 같은 repo · number 면 한 행 + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + continue-on-error: true # Prober 가 죽어도 PR 을 막지 않는다 + permissions: + contents: read + pull-requests: read + steps: + - name: PR 올리기 + env: + GH_TOKEN: ${{ github.token }} + PROBER_API: https://api.prober.im + PROBER_TOKEN: ${{ secrets.PROBER_TOKEN }} + PROBER_PROJECT: 8a05d363-c757-4bba-90f1-680083d7527b/projects/47d37bd7-a00a-4c6b-9ae9-37aa60f43dfc + PR: ${{ github.event.pull_request.number }} + run: | + gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/commits" --paginate \ + --jq '[.[] | { sha, message: .commit.message, author: .commit.author.name, committedAt: .commit.author.date }]' | jq -s add > commits.json + gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq \ + '{ repo: .base.repo.full_name, number, title, url: .html_url, body: (.body // ""), + state: (if .merged_at then "merged" elif .state == "closed" then "closed" else "open" end), + baseBranch: .base.ref, headBranch: .head.ref, headSha: .head.sha, mergedAt: .merged_at }' > pr.json + jq --slurpfile c commits.json '. + { commits: $c[0] }' pr.json > body.json + curl -fsS -X PUT "$PROBER_API/teams/$PROBER_PROJECT/prs" \ + -H "authorization: Bearer $PROBER_TOKEN" -H 'content-type: application/json' --data @body.json >/dev/null diff --git a/.github/workflows/strata-knowledge.yaml b/.github/workflows/strata-knowledge.yaml deleted file mode 100644 index 57593c782..000000000 --- a/.github/workflows/strata-knowledge.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# 지도 위층(문서 · 개념 · 흐름)을 Strata 로 보낸다 — specs/*/spec.md · docs/erd · docs/flows 와 .strata/concepts.json · .strata/flows/*.json. -# 테이블과 잇는 건 Strata 가 읽을 때 한다. 적힌 테이블이 DB 구조에 없으면 경고로 찍는다(실패는 아니다). -# beta push 가 지도의 기준이다. AGENT.md §작업 룰 「.strata」 -name: strata-knowledge - -on: - pull_request: - branches: [beta, develop, main] - paths: ['specs/**', 'docs/erd/**', 'docs/flows/**', '.strata/concepts.json', '.strata/flows/**', 'scripts/strata/collect-knowledge.cjs', '.github/workflows/strata-knowledge.yaml'] - push: - branches: [beta] - paths: ['specs/**', 'docs/erd/**', 'docs/flows/**', '.strata/concepts.json', '.strata/flows/**'] - workflow_dispatch: - -jobs: - knowledge: - # 포크 PR 에는 시크릿이 없다 - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - - name: Strata 에 올리기 - env: - STRATA_API: https://strata-api.studyclub.life - STRATA_TOKEN: ${{ secrets.STRATA_TOKEN }} - STRATA_PROJECT: a6cb62a5-1e29-4d59-b76a-5ceba5119580/projects/fa485ab4-54ec-43bc-a9b5-9b330d30687c - BASE_BRANCH: beta - PR_NUMBER: ${{ github.event.pull_request.number }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: node scripts/strata/collect-knowledge.cjs diff --git a/.strata/concepts.json b/.prober/map/concepts.json similarity index 100% rename from .strata/concepts.json rename to .prober/map/concepts.json diff --git a/.strata/flows/discord-attendance.json b/.prober/map/flows/discord-attendance.json similarity index 100% rename from .strata/flows/discord-attendance.json rename to .prober/map/flows/discord-attendance.json diff --git a/.strata/flows/study-apply.json b/.prober/map/flows/study-apply.json similarity index 100% rename from .strata/flows/study-apply.json rename to .prober/map/flows/study-apply.json diff --git a/.strata/flows/user-leave.json b/.prober/map/flows/user-leave.json similarity index 100% rename from .strata/flows/user-leave.json rename to .prober/map/flows/user-leave.json diff --git a/AGENT.md b/AGENT.md index 9fd474521..985128273 100644 --- a/AGENT.md +++ b/AGENT.md @@ -154,12 +154,12 @@ cd backend && ./gradlew :api:bootRun # JDK 25 필요. Gradle 은 wrapp **playground 만 `beta` 브랜치에서 배포된다** (`playground-beta.yaml`) — 프로토타입이라 main 을 기다리지 않는다. `backend-PR-CI` 는 PR 마다 `./gradlew check` 를 돌린다 — 컴파일 실패·포맷 위반이 머지되는 걸 막는 게이트. `backend-migration-check` 는 PR 마다 빈 MySQL 에 마이그레이션을 적용해 본다 — 여기서 깨지면 `V*.sql` 을 고친다. -- **디스코드 봇의 `CREATE TABLE` 을 고치면 같은 커밋에서 `discord/.strata/schema.json` 도 다시 뽑는다** — - `cd discord && python -m tests.test_strata_schema > .strata/schema.json`. 봇은 백엔드 MySQL 과 별개로 SQLite 테이블을 쓰고, - Strata(DB 구조 지도)는 이 파일을 그대로 읽는다(`discord-strata`). 파일이 실제 테이블과 다르면 `discord-PR-CI` 의 pytest 가 막는다. -- **`.strata/` 는 Strata 지도의 위층이다** — `concepts.json` 은 테이블을 개념 묶음으로 나누고 이름으로 못 찾는 관계(`CREATED_BY` → ACCOUNT, 디스코드 snowflake 등)를 적는다. +- **디스코드 봇의 `CREATE TABLE` 을 고치면 같은 커밋에서 `discord/.prober/map/schema.json` 도 다시 뽑는다** — + `cd discord && python -m tests.test_prober_schema > .prober/map/schema.json`. 봇은 백엔드 MySQL 과 별개로 SQLite 테이블을 쓰고, + Prober(DB 구조 지도)는 이 파일을 그대로 읽는다(`prober-map`). 파일이 실제 테이블과 다르면 `discord-PR-CI` 의 pytest 가 막는다. +- **`.prober/map/` 는 Prober 지도의 위층이다** — `concepts.json` 은 테이블을 개념 묶음으로 나누고 이름으로 못 찾는 관계(`CREATED_BY` → ACCOUNT, 디스코드 snowflake 등)를 적는다. `flows/*.json` 은 흐름 하나의 단계 순서(테이블 · op · 이벤트 · 시스템 밖). 테이블을 추가·삭제·이름 변경했거나 스펙의 처리 순서를 바꿨으면 같은 PR 에서 고친다. - `strata-knowledge` CI 가 스펙·`docs/erd`·`docs/flows` 와 함께 올리고, DB 구조에 없는 테이블을 가리키면 경고를 찍는다. + `prober-map` CI 가 스펙·`docs/erd`·`docs/flows` 와 함께 올리고, DB 구조에 없는 테이블을 가리키면 경고를 찍는다. --- diff --git a/discord/.strata/schema.json b/discord/.prober/map/schema.json similarity index 100% rename from discord/.strata/schema.json rename to discord/.prober/map/schema.json diff --git a/discord/tests/test_strata_schema.py b/discord/tests/test_prober_schema.py similarity index 82% rename from discord/tests/test_strata_schema.py rename to discord/tests/test_prober_schema.py index 10d9de52f..aed1cc5da 100644 --- a/discord/tests/test_strata_schema.py +++ b/discord/tests/test_prober_schema.py @@ -1,12 +1,12 @@ -"""discord/.strata/schema.json matches the tables the bot really creates. +"""discord/.prober/map/schema.json matches the tables the bot really creates. -Strata (the DB-structure map) reads that file as-is; nothing here runs a DB in CI +Prober (the DB-structure map) reads that file as-is; nothing here runs a DB in CI for it. So the file is hand- (or AI-) maintained next to the CREATE TABLE statements, and this test is what keeps the two from drifting: it lets each store create its table in a scratch SQLite file and compares PRAGMA output. After changing a CREATE TABLE, regenerate with: - python -m tests.test_strata_schema > .strata/schema.json + python -m tests.test_prober_schema > .prober/map/schema.json """ from __future__ import annotations @@ -19,7 +19,7 @@ from app.study_reservations import StudyReservations from app.voice_activity_log import VoiceActivityLog -SCHEMA = Path(__file__).resolve().parents[1] / ".strata" / "schema.json" +SCHEMA = Path(__file__).resolve().parents[1] / ".prober" / "map" / "schema.json" STORES = (StudyReservations, VoiceActivityLog) @@ -49,7 +49,7 @@ def actual() -> dict: def test_schema_file_matches_the_tables_the_bot_creates(): assert json.loads(SCHEMA.read_text()) == actual(), ( - "discord/.strata/schema.json is stale -- run: python -m tests.test_strata_schema > .strata/schema.json" + "discord/.prober/map/schema.json is stale -- run: python -m tests.test_prober_schema > .prober/map/schema.json" ) diff --git a/scripts/strata/collect-knowledge.cjs b/scripts/prober/collect-knowledge.cjs similarity index 60% rename from scripts/strata/collect-knowledge.cjs rename to scripts/prober/collect-knowledge.cjs index 5fda20385..e2c0aa569 100644 --- a/scripts/strata/collect-knowledge.cjs +++ b/scripts/prober/collect-knowledge.cjs @@ -1,13 +1,14 @@ #!/usr/bin/env node -// 사본 — 정본은 IgniterCorp/strata-engineering actions/knowledge-snapshot/collect.cjs (그 레포가 비공개라 액션으로 못 부른다) -// 레포의 위층(문서 · 개념 · 흐름)을 모아 Strata 에 올린다. specs/schema §위층 -// node collect.cjs → 올린다 (env STRATA_API · STRATA_TOKEN · STRATA_PROJECT=<팀>/projects/<프로젝트>) +// 레포의 위층(문서 · 개념 · 흐름)을 모아 Prober 에 올린다. specs/schema §위층 +// node collect.cjs → 올린다 (env PROBER_API · PROBER_TOKEN · PROBER_PROJECT=<팀>/projects/<프로젝트>) // node collect.cjs --dry-run → 저장하지 않고 "DB 구조에 없는 테이블" 경고만 본다 (로컬 검사) // node collect.cjs --print → 보낼 body 만 찍는다 (네트워크 없음) +// PR 브랜치면 응답의 기준 대비 비교를 knowledge-comment.md 로 쓴다 (action.yml 이 PR 코멘트로) // 읽는 것 (레포 루트 기준, env 로 바꿀 수 있다) -// STRATA_DOCS = specs/*/spec.md,docs/erd/*.md,docs/flows/*.md -// .strata/concepts.json = { concepts: [{ name, summary?, tables[] }], relations: [{ from: 'TABLE.COLUMN', to, why? }] } -// .strata/flows/*.json = { name, trigger?, spec?, steps: [{ table? | external?, op?, event?, note? }] } +// PROBER_DOCS = specs/*/spec.md,docs/erd/*.md,docs/flows/*.md +// /concepts.json = { concepts: [{ name, summary?, tables[] }], relations: [{ from: 'TABLE.COLUMN', to, why? }] } +// /flows/*.json = { name, trigger?, spec?, steps: [{ table? | external?, op?, event?, note? }] } +// = 옛 폴더 .strata 가 있으면 그것, 없으면 .prober/map (옛 폴더 지원은 한 분기 뒤 삭제) const fs = require('node:fs'); const path = require('node:path'); const { execFileSync } = require('node:child_process'); @@ -43,15 +44,25 @@ function glob(pattern) { } const readJson = (p) => JSON.parse(fs.readFileSync(path.join(root, p), 'utf8')); -const patterns = (process.env.STRATA_DOCS || 'specs/*/spec.md,docs/erd/*.md,docs/flows/*.md').split(',').filter(Boolean); +const patterns = (process.env.PROBER_DOCS || 'specs/*/spec.md,docs/erd/*.md,docs/flows/*.md').split(',').filter(Boolean); const docs = [...new Set(patterns.flatMap(glob))].map((p) => ({ path: p, body: fs.readFileSync(path.join(root, p), 'utf8') })); -const concepts = fs.existsSync(path.join(root, '.strata/concepts.json')) ? readJson('.strata/concepts.json') : { concepts: [], relations: [] }; -const flows = glob('.strata/flows/*.json').map((p) => { +const map = fs.existsSync(path.join(root, '.strata')) ? '.strata' : '.prober/map'; +const concepts = fs.existsSync(path.join(root, map, 'concepts.json')) ? readJson(`${map}/concepts.json`) : { concepts: [], relations: [] }; +const flows = glob(`${map}/flows/*.json`).map((p) => { const f = readJson(p); if (!f.name || !Array.isArray(f.steps)) throw new Error(`${p}: { name, steps: [...] } 모양이 아니다`); return f; }); +// PR 이 고친 파일 — 액션은 gh pr diff 결과를 PROBER_CHANGED_FILE 로 준다. 로컬은 merge-base 로. 못 구하면 전부 비교 +function changedPaths() { + if (process.env.PROBER_CHANGED_FILE && fs.existsSync(process.env.PROBER_CHANGED_FILE)) + return fs.readFileSync(process.env.PROBER_CHANGED_FILE, 'utf8').split('\n').filter(Boolean); + const base = process.env.BASE_BRANCH || 'main'; + const mb = git('merge-base', `origin/${base}`, 'HEAD'); + return mb ? git('diff', '--name-only', `${mb}...HEAD`).split('\n').filter(Boolean) : undefined; +} + const remote = git('remote', 'get-url', 'origin').match(/[:/]([^/:]+\/[^/]+?)(\.git)?$/)?.[1]; const body = { repo: process.env.GITHUB_REPOSITORY || remote, @@ -63,6 +74,7 @@ const body = { relations: concepts.relations ?? [], flows, docs, + ...(changedPaths() ? { changed: changedPaths() } : {}), ...(args.includes('--dry-run') ? { dryRun: true } : {}), }; @@ -72,23 +84,28 @@ if (args.includes('--print')) { } (async () => { - const api = (process.env.STRATA_API || 'https://strata-api.igniter.im').replace(/\/$/, ''); - const res = await fetch(`${api}/teams/${process.env.STRATA_PROJECT}/schema/knowledge`, { + const api = (process.env.PROBER_API || 'https://api.prober.im').replace(/\/$/, ''); + const res = await fetch(`${api}/teams/${process.env.PROBER_PROJECT}/schema/knowledge`, { method: 'POST', - headers: { authorization: `Bearer ${process.env.STRATA_TOKEN}`, 'content-type': 'application/json' }, + headers: { authorization: `Bearer ${process.env.PROBER_TOKEN}`, 'content-type': 'application/json' }, body: JSON.stringify(body), }); const text = await res.text(); if (!res.ok) { - console.log(`::error::Strata 가 ${res.status} 로 거절 — ${text.slice(0, 300)}`); + console.log(`::error::Prober 가 ${res.status} 로 거절 — ${text.slice(0, 300)}`); process.exit(1); } const out = JSON.parse(text); for (const w of out.warnings) console.log(`::warning::${w}`); const c = out.counts; console.log(`${out.saved ? '올렸다' : '검사만 했다'} — 개념 ${c.concepts} · 선언 관계 ${c.relations} · 흐름 ${c.flows} · 문서 ${c.docs}(테이블과 이어짐 ${c.linkedDocs}) · 경고 ${out.warnings.length}`); + // PR 이면 기준 브랜치와 견준 코멘트 본문이 온다 — 액션 다음 스텝이 PR 에 단다 (로컬 --dry-run 은 화면에) + if (out.comment) { + fs.writeFileSync('knowledge-comment.md', out.comment); + if (args.includes('--dry-run')) console.log(`\n${out.comment}`); + } if (process.env.GITHUB_STEP_SUMMARY) - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `### Strata 위층 · ${body.repo}\n\n개념 ${c.concepts} · 흐름 ${c.flows} · 문서 ${c.docs} (테이블과 이어짐 ${c.linkedDocs})\n\n${out.warnings.map((w) => `- ⚠️ ${w}`).join('\n')}\n`); + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `### Prober 위층 · ${body.repo}\n\n개념 ${c.concepts} · 흐름 ${c.flows} · 문서 ${c.docs} (테이블과 이어짐 ${c.linkedDocs})\n\n${out.warnings.map((w) => `- ⚠️ ${w}`).join('\n')}\n`); })().catch((e) => { console.log(`::error::${e.message}`); process.exit(1); diff --git a/scripts/strata/erd.sql b/scripts/prober/erd.sql similarity index 96% rename from scripts/strata/erd.sql rename to scripts/prober/erd.sql index 44706a814..da3250eec 100644 --- a/scripts/strata/erd.sql +++ b/scripts/prober/erd.sql @@ -1,4 +1,4 @@ --- 현재 DB 의 구조를 erd.json 한 덩어리로 — 언어·ORM 무관 (mysql CLI 만 있으면 된다). Strata specs/schema — 이 파일은 Strata 수집기 사본 +-- 현재 DB 의 구조를 erd.json 한 덩어리로 — 언어·ORM 무관 (mysql CLI 만 있으면 된다). specs/schema §수집기 -- mysql --default-character-set=utf8mb4 -h -u -p -N -B -r < erd.sql > erd.json -- (문자셋을 안 주면 한글 default 가 ?? 로 깨진다) -- 순서가 필요한 배열은 GROUP_CONCAT … ORDER BY 로 만든다 — JSON_ARRAYAGG 는 순서를 보장하지 않는다 From 0378b3593bf95817650a9ab503cd962397ebb522 Mon Sep 17 00:00:00 2001 From: titaniper Date: Thu, 29 Oct 2026 23:21:24 +0900 Subject: [PATCH 7/8] =?UTF-8?q?ci(prober-map):=20PR=20=EC=98=AC=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0=20=EC=8B=A4=ED=8C=A8=EB=8A=94=20=EA=B2=BD=EA=B3=A0?= =?UTF-8?q?=EB=A1=9C,=20=EC=A0=9C=EB=AA=A9=20=EC=88=98=EC=A0=95=EC=9D=80?= =?UTF-8?q?=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=EC=97=90=EC=84=9C=20=EB=BA=80?= =?UTF-8?q?=EB=8B=A4=20(=EC=A7=80=EB=8F=84=20job=20=EC=9D=B4=20skipped=20?= =?UTF-8?q?=EB=A1=9C=20=EB=8D=AE=EC=9D=B4=EC=A7=80=20=EC=95=8A=EA=B2=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .github/workflows/prober-map.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/prober-map.yml b/.github/workflows/prober-map.yml index 53062d812..95547288d 100644 --- a/.github/workflows/prober-map.yml +++ b/.github/workflows/prober-map.yml @@ -4,14 +4,14 @@ name: prober-map on: pull_request: - types: [opened, edited, synchronize, reopened, closed] + types: [opened, synchronize, reopened, closed] push: branches: [beta] workflow_dispatch: jobs: changes: - # 포크 PR 에는 시크릿이 없다. 닫힘 · 제목 수정은 지도를 다시 올리지 않는다 + # 포크 PR 에는 시크릿이 없다. 닫힘은 지도를 다시 올리지 않는다 runs-on: ubuntu-latest permissions: contents: read @@ -32,7 +32,7 @@ jobs: run: | if [ "$EVENT" = workflow_dispatch ]; then for kv in discord_schema=true knowledge=true; do echo "$kv" >> "$GITHUB_OUTPUT"; done; exit 0; fi if [ "$EVENT" = pull_request ]; then - if [ "$FORK" = true ] || [ "$ACTION" = closed ] || [ "$ACTION" = edited ]; then : > files.txt + if [ "$FORK" = true ] || [ "$ACTION" = closed ]; then : > files.txt else gh pr diff "$PR" --repo "$GITHUB_REPOSITORY" --name-only > files.txt; fi else gh api "repos/$GITHUB_REPOSITORY/compare/$BEFORE...$AFTER" --jq '.files[].filename' > files.txt || echo .github/workflows/prober-map.yml > files.txt @@ -131,4 +131,5 @@ jobs: baseBranch: .base.ref, headBranch: .head.ref, headSha: .head.sha, mergedAt: .merged_at }' > pr.json jq --slurpfile c commits.json '. + { commits: $c[0] }' pr.json > body.json curl -fsS -X PUT "$PROBER_API/teams/$PROBER_PROJECT/prs" \ - -H "authorization: Bearer $PROBER_TOKEN" -H 'content-type: application/json' --data @body.json >/dev/null + -H "authorization: Bearer $PROBER_TOKEN" -H 'content-type: application/json' --data @body.json >/dev/null \ + || echo "::warning::Prober PR 올리기 실패 — PR 화면 API 가 아직 없거나 Prober 가 응답하지 않음" From 0770581cba916a836bea05a09cf946c1a734d4af Mon Sep 17 00:00:00 2001 From: titaniper Date: Thu, 29 Oct 2026 23:51:07 +0900 Subject: [PATCH 8/8] =?UTF-8?q?ci(prober-map):=20PR=20=EC=98=AC=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20API=20=ED=95=9C=EB=8F=84=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=98=EB=8B=A4=20=E2=80=94=20=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20500=EC=9E=90=20=C2=B7=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=20500=EA=B0=9C=20=C2=B7=20=EB=B3=B8=EB=AC=B8=2020?= =?UTF-8?q?=EB=A7=8C=20=EC=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .github/workflows/prober-map.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/prober-map.yml b/.github/workflows/prober-map.yml index 95547288d..8bfad42db 100644 --- a/.github/workflows/prober-map.yml +++ b/.github/workflows/prober-map.yml @@ -124,9 +124,9 @@ jobs: PR: ${{ github.event.pull_request.number }} run: | gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/commits" --paginate \ - --jq '[.[] | { sha, message: .commit.message, author: .commit.author.name, committedAt: .commit.author.date }]' | jq -s add > commits.json + --jq '[.[] | { sha, message: .commit.message[0:500], committedAt: .commit.author.date }]' | jq -s 'add | .[-500:]' > commits.json gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq \ - '{ repo: .base.repo.full_name, number, title, url: .html_url, body: (.body // ""), + '{ repo: .base.repo.full_name, number, title, url: .html_url, body: ((.body // "")[0:200000]), state: (if .merged_at then "merged" elif .state == "closed" then "closed" else "open" end), baseBranch: .base.ref, headBranch: .head.ref, headSha: .head.sha, mergedAt: .merged_at }' > pr.json jq --slurpfile c commits.json '. + { commits: $c[0] }' pr.json > body.json