From 8d2f7a0b931bdc63d31e66142ddcf1f1abaf043a Mon Sep 17 00:00:00 2001 From: soobing Date: Mon, 27 Apr 2026 21:30:54 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20API=20=EC=82=AC=EC=9A=A9=EB=9F=89=20?= =?UTF-8?q?=EB=A7=88=EC=BB=A4=20=EC=A0=95=EA=B7=9C=EC=8B=9D=EC=9D=B4=20?= =?UTF-8?q?=EB=B0=B0=EC=97=B4=20=ED=8F=AC=EB=A7=B7=EC=9D=84=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit USAGE_DATA_RE 가 `({.*?})` 로 객체 패턴만 캡처하던 탓에, 실제 저장 포맷인 객체 배열 `[{prompt,completion}, ...]` 에서 첫 객체만 잡혔고 Array.isArray 분기에서 항상 빈 배열로 떨어져 직전 누적 호출 기록이 사라졌다. 결과적으로 PR 갱신 시 `🔢 API 사용량` 표가 항상 최근 1행만 표시되었다 (#36). - USAGE_DATA_RE 를 `(\[.*?\])` 로 변경하고 문서 주석을 실제 포맷에 맞춤 - tests/learningComment.test.js 추가: 누적 동작 / 손상된 마커 폴백 / usage 미제공 시 섹션 생략 회귀 테스트 - AGENTS.md 에 디버깅 기록 추가 Co-Authored-By: Claude Opus 4.7 Co-Authored-By: sounmind <37020415+sounmind@users.noreply.github.com> --- AGENTS.md | 10 ++ tests/learningComment.test.js | 179 ++++++++++++++++++++++++++++++++++ utils/learningComment.js | 11 ++- 3 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 tests/learningComment.test.js diff --git a/AGENTS.md b/AGENTS.md index e1d4de1..567c2f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -487,6 +487,16 @@ describe("checkWeeks", () => { - GitHub 인증 로직 (`generateGitHubAppToken`, `createJWT` 등)은 모든 기능에서 공통으로 사용 - 새 기능 추가 시 기존 유틸리티 함수 활용 +## 디버깅 기록 + +### 학습 현황 코멘트 — `🔢 API 사용량` 누적이 안 되던 문제 (이슈 #36) + +- **증상**: 같은 PR에 새 커밋이 push되어 webhook이 다시 발화하면 `🔢 API 사용량 (gpt-4.1-nano)` 표가 누적되어야 하는데, 항상 가장 최근 호출 1행만 표시되고 `합계` 행도 절대 나오지 않았다. +- **원인**: `utils/learningComment.js`의 숨김 마커 정규식 `USAGE_DATA_RE`가 `({.*?})`로 객체 패턴만 캡처하도록 작성돼 있었다. 실제 저장 포맷은 `JSON.stringify(history)` 결과인 객체 **배열**(`[{prompt,completion}, ...]`)이어서, 정규식은 배열 안의 첫 객체 `{...}`만 캡처했다. 결과적으로 `JSON.parse`는 단일 객체를 반환했고, `Array.isArray(parsed) ? parsed : []` 분기에서 항상 빈 배열로 떨어져 직전 누적값이 통째로 사라졌다. 문서 주석은 단일 객체 포맷이라고 적혀 있었지만 코드는 이미 배열 포맷으로 옮겨간 상태였다. +- **수정**: 정규식을 `(\[.*?\])`로 변경해 배열 포맷을 캡처하고, 문서 주석을 실제 저장 포맷에 맞춰 갱신. 잘못된 포맷의 기존 코멘트는 `parseUsageFromComment`가 자연스럽게 `[]`로 떨어져 새 호출부터 다시 누적된다. +- **회귀 방지**: `tests/learningComment.test.js`에 ① 신규 코멘트 1행, ② 기존 코멘트의 배열 마커로부터 #1~#3 누적 + 합계 행 검증, ③ 손상된 객체 마커는 단일행으로 리셋, ④ usage 미제공 시 섹션 자체가 빠짐 — 4개 시나리오를 박아 두었다. +- **교훈**: 직렬화 포맷을 객체→배열로 옮길 때 정규식·문서 주석·테스트가 함께 따라가지 않으면 조용히 실패한다. 특히 `Array.isArray` 같은 방어 코드가 잘못된 입력을 throw 없이 빈 배열로 무마하면 디버깅이 더 어려워진다. + ## 관련 문서 - [Cloudflare Workers Docs](https://developers.cloudflare.com/workers/) diff --git a/tests/learningComment.test.js b/tests/learningComment.test.js new file mode 100644 index 0000000..eb74a3d --- /dev/null +++ b/tests/learningComment.test.js @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeEach } from "bun:test"; + +import { upsertLearningStatusComment } from "../utils/learningComment.js"; + +const REPO_OWNER = "DaleStudy"; +const REPO_NAME = "leetcode-study"; +const PR_NUMBER = 42; +const APP_TOKEN = "fake-app-token"; +const COMMENT_MARKER = ""; + +function ok(body) { + return Promise.resolve({ + ok: true, + status: 200, + statusText: "OK", + json: () => Promise.resolve(body), + }); +} + +function parseBody(call) { + return JSON.parse(call.init.body).body; +} + +describe("upsertLearningStatusComment — usage history accumulation", () => { + let originalFetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + it("posts a new comment with a single usage row when no prior comment exists", async () => { + const calls = []; + globalThis.fetch = (url, init = {}) => { + calls.push({ url: String(url), init }); + if (init.method === "POST") return ok({}); + return ok([]); // no existing comments + }; + + await upsertLearningStatusComment( + REPO_OWNER, + REPO_NAME, + PR_NUMBER, + `${COMMENT_MARKER}\n## body`, + APP_TOKEN, + { prompt_tokens: 100, completion_tokens: 50 } + ); + + const post = calls.find((c) => c.init.method === "POST"); + expect(post).toBeDefined(); + + const body = parseBody(post); + expect(body).toContain("🔢 API 사용량 (gpt-4.1-nano)"); + expect(body).toContain("| #1 | 100 | 50 | 150 |"); + // single-row history => no totals row + expect(body).not.toContain("**합계**"); + // hidden marker stores an array + expect(body).toMatch(//); + + globalThis.fetch = originalFetch; + }); + + it("accumulates usage rows across PR updates when prior usage marker is present", async () => { + const previousBody = [ + COMMENT_MARKER, + "## existing body", + "", + ``, + ].join("\n"); + + const calls = []; + globalThis.fetch = (url, init = {}) => { + const u = String(url); + calls.push({ url: u, init }); + if (u.includes(`/issues/${PR_NUMBER}/comments`) && (!init.method || init.method === "GET")) { + return ok([ + { + id: 999, + user: { type: "Bot" }, + body: previousBody, + }, + ]); + } + if (init.method === "PATCH") return ok({}); + return ok([]); + }; + + await upsertLearningStatusComment( + REPO_OWNER, + REPO_NAME, + PR_NUMBER, + `${COMMENT_MARKER}\n## new body`, + APP_TOKEN, + { prompt_tokens: 300, completion_tokens: 120 } + ); + + const patch = calls.find((c) => c.init.method === "PATCH"); + expect(patch).toBeDefined(); + expect(patch.url).toContain("/issues/comments/999"); + + const body = parseBody(patch); + // all three calls present, in order + expect(body).toContain("| #1 | 100 | 50 | 150 |"); + expect(body).toContain("| #2 | 200 | 80 | 280 |"); + expect(body).toContain("| #3 | 300 | 120 | 420 |"); + // totals row appears once history.length > 1 + expect(body).toContain("| **합계** | **600** | **250** | **850** |"); + // marker is rewritten with the full array + expect(body).toMatch( + // + ); + + globalThis.fetch = originalFetch; + }); + + it("falls back to a single-row history when the prior marker is malformed", async () => { + // legacy / corrupt marker (object instead of array) — should not crash, just reset + const previousBody = [ + COMMENT_MARKER, + "## existing body", + "", + ``, + ].join("\n"); + + const calls = []; + globalThis.fetch = (url, init = {}) => { + const u = String(url); + calls.push({ url: u, init }); + if (u.includes(`/issues/${PR_NUMBER}/comments`) && (!init.method || init.method === "GET")) { + return ok([ + { id: 1, user: { type: "Bot" }, body: previousBody }, + ]); + } + if (init.method === "PATCH") return ok({}); + return ok([]); + }; + + await upsertLearningStatusComment( + REPO_OWNER, + REPO_NAME, + PR_NUMBER, + `${COMMENT_MARKER}\n## new body`, + APP_TOKEN, + { prompt_tokens: 999, completion_tokens: 111 } + ); + + const patch = calls.find((c) => c.init.method === "PATCH"); + const body = parseBody(patch); + + expect(body).toContain("| #1 | 999 | 111 | 1,110 |"); + expect(body).not.toContain("**합계**"); + + globalThis.fetch = originalFetch; + }); + + it("omits the usage section entirely when no usage is provided", async () => { + const calls = []; + globalThis.fetch = (url, init = {}) => { + calls.push({ url: String(url), init }); + if (init.method === "POST") return ok({}); + return ok([]); + }; + + await upsertLearningStatusComment( + REPO_OWNER, + REPO_NAME, + PR_NUMBER, + `${COMMENT_MARKER}\n## body`, + APP_TOKEN + // no usage + ); + + const post = calls.find((c) => c.init.method === "POST"); + const body = parseBody(post); + expect(body).not.toContain("🔢 API 사용량"); + expect(body).not.toContain("usage-data:"); + + globalThis.fetch = originalFetch; + }); +}); diff --git a/utils/learningComment.js b/utils/learningComment.js index 28b34f1..4dfad34 100644 --- a/utils/learningComment.js +++ b/utils/learningComment.js @@ -10,10 +10,15 @@ import { getGitHubHeaders } from "./github.js"; const COMMENT_MARKER = ""; /** - * Hidden marker for embedding cumulative usage data in the comment. - * Format: + * Hidden marker for embedding per-request usage history in the comment. + * Format: + * + * The capture group must match the array — earlier versions of this regex + * matched only `{...}` and silently captured the first object inside the + * array, which made `parseUsageFromComment` always return `[]` and broke + * cumulative aggregation across PR updates. */ -const USAGE_DATA_RE = //; +const USAGE_DATA_RE = //; /** gpt-4.1-nano pricing (USD per token) */ const INPUT_COST_PER_TOKEN = 0.10 / 1_000_000; From ada38f8765690940612910c327e9d15e4fe92336 Mon Sep 17 00:00:00 2001 From: soobing Date: Mon, 27 Apr 2026 21:46:34 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20AGENTS.md=20=EB=94=94=EB=B2=84?= =?UTF-8?q?=EA=B9=85=20=EA=B8=B0=EB=A1=9D=20=EC=84=B9=EC=85=98=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=ED=95=98=EA=B3=A0=20=EC=9D=BC=EB=B0=98=ED=99=94?= =?UTF-8?q?=EB=90=9C=20=EA=B7=9C=EC=B9=99=EC=9C=BC=EB=A1=9C=20=EB=8C=80?= =?UTF-8?q?=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit incident log 형태는 시간이 지나며 무관해지기 쉬워, 코멘트 숨김 마커 직렬화 포맷 변경 시의 일반화된 주의사항 한 항목만 `코드 수정 시 주의사항`에 남긴다. 구체적 사건 서사는 커밋 메시지/PR/코드 주석에 이미 보존돼 있다. Co-Authored-By: Claude Opus 4.7 Co-Authored-By: sounmind <37020415+sounmind@users.noreply.github.com> --- AGENTS.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 567c2f2..aed8e74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -487,15 +487,9 @@ describe("checkWeeks", () => { - GitHub 인증 로직 (`generateGitHubAppToken`, `createJWT` 등)은 모든 기능에서 공통으로 사용 - 새 기능 추가 시 기존 유틸리티 함수 활용 -## 디버깅 기록 - -### 학습 현황 코멘트 — `🔢 API 사용량` 누적이 안 되던 문제 (이슈 #36) - -- **증상**: 같은 PR에 새 커밋이 push되어 webhook이 다시 발화하면 `🔢 API 사용량 (gpt-4.1-nano)` 표가 누적되어야 하는데, 항상 가장 최근 호출 1행만 표시되고 `합계` 행도 절대 나오지 않았다. -- **원인**: `utils/learningComment.js`의 숨김 마커 정규식 `USAGE_DATA_RE`가 `({.*?})`로 객체 패턴만 캡처하도록 작성돼 있었다. 실제 저장 포맷은 `JSON.stringify(history)` 결과인 객체 **배열**(`[{prompt,completion}, ...]`)이어서, 정규식은 배열 안의 첫 객체 `{...}`만 캡처했다. 결과적으로 `JSON.parse`는 단일 객체를 반환했고, `Array.isArray(parsed) ? parsed : []` 분기에서 항상 빈 배열로 떨어져 직전 누적값이 통째로 사라졌다. 문서 주석은 단일 객체 포맷이라고 적혀 있었지만 코드는 이미 배열 포맷으로 옮겨간 상태였다. -- **수정**: 정규식을 `(\[.*?\])`로 변경해 배열 포맷을 캡처하고, 문서 주석을 실제 저장 포맷에 맞춰 갱신. 잘못된 포맷의 기존 코멘트는 `parseUsageFromComment`가 자연스럽게 `[]`로 떨어져 새 호출부터 다시 누적된다. -- **회귀 방지**: `tests/learningComment.test.js`에 ① 신규 코멘트 1행, ② 기존 코멘트의 배열 마커로부터 #1~#3 누적 + 합계 행 검증, ③ 손상된 객체 마커는 단일행으로 리셋, ④ usage 미제공 시 섹션 자체가 빠짐 — 4개 시나리오를 박아 두었다. -- **교훈**: 직렬화 포맷을 객체→배열로 옮길 때 정규식·문서 주석·테스트가 함께 따라가지 않으면 조용히 실패한다. 특히 `Array.isArray` 같은 방어 코드가 잘못된 입력을 throw 없이 빈 배열로 무마하면 디버깅이 더 어려워진다. +7. **코멘트 숨김 마커 직렬화 포맷 변경** + - 코멘트에 `` 형태로 숨겨 저장하는 데이터의 직렬화 포맷(객체↔배열 등)을 바꿀 때는 **정규식·문서 주석·테스트를 같은 PR에서 함께 갱신** + - 파싱이 `Array.isArray` 같은 방어 코드로 빈 값에 fallback하면 회귀가 조용히 묻혀 디버깅이 어려워짐 ## 관련 문서